Skip to content

Repository files navigation

Off-plan escrow

Buyer deposits are held until construction evidence passes verification. Two of three independent attesters release each milestone. If the developer walks away, whatever has not been released comes back pro rata.

TypeScript port of the Python reference implementation, preserved in git history at the Add Python reference implementation commit. Next.js on Vercel, Foundry for contracts, Postgres via Drizzle. All 63 reference test assertions have passing equivalents; the escrow suite runs under Foundry and the evidence and data suites under Vitest, offline.

Layout

contracts/src/PropertyEscrow.sol   escrow, attestation, refunds (unchanged)
contracts/src/MockKES.sol          KES-pegged test token, 2 decimals (unchanged)
contracts/test/PropertyEscrow.t.sol  the four money-path scenarios, 22 assertions
contracts/script/Deploy.s.sol      deployment to Fuji or Anvil
lib/evidence/                      EXIF geofence, recency, perceptual hash, stage
lib/data/                          cached fetch, Nominatim/Overpass, register adapters
lib/db/                            Drizzle schema; migrations in drizzle/
lib/chain.ts                       viem clients, ABIs, managed buyer accounts
lib/daraja.ts                      M-Pesa STK push and callback schemas
app/api/                           deposit, mpesa/callback, evidence, attest,
                                   corroborate, state, stall, refund
app/page.tsx                       the drawdown register console
fixtures/                          committed offline cache and register CSVs
tests/                             Vitest suites (evidence 20, data 24, store 4)

Run it

git submodule update --init   # forge-std, needed by the contract tests
npm install
npm test                      # 48 assertions, fully offline
npm run test:contracts        # 22 assertions under Foundry

The full app needs a chain, a database, and configuration:

cp .env.example .env    # fill in keys and addresses

# Local chain: an Anvil fork, then deploy
anvil &
cd contracts && forge script script/Deploy.s.sol \
  --rpc-url http://127.0.0.1:8545 --broadcast
# put the printed addresses in .env with RPC_URL=http://127.0.0.1:8545

# Database
npm run db:migrate

npm run dev             # console on http://localhost:3000

For Fuji, deploy with --rpc-url https://api.avax-test.network/ext/bc/C/rpc using three separately funded keys so two-of-three is real rather than one account signing twice.

The evidence pipeline

Four checks on every submitted image, same semantics and rejection wording as the reference: EXIF GPS inside the site geofence (exifr returns decimal degrees, so the DMS conversion helper is gone), capture timestamp inside the reporting window with future timestamps rejected separately from stale ones, perceptual hash within Hamming distance 6 of nothing previously accepted, and classified stage matching the claim at confidence 0.6 or better.

The perceptual hash is owned, not imported: 32x32 greyscale through sharp, DCT-II, top-left 8x8 coefficient block, threshold against the median, packed to 64 bits. Seen hashes live in Postgres as BIGINT and the Hamming comparison runs in SQL, so the check holds across serverless instances. The bundle hash over a submission is byte-compatible with the reference — a fixed-input vector generated by the Python implementation is asserted in the tests, along with stability across runs and sensitivity to every input.

Stage classification sits behind one interface. The default backend calls a vision-language model over fetch with a zod-validated JSON response (ANTHROPIC_API_KEY; server-side refusal fallback enabled); the sidecar backend reads a label file uploaded with the image so tests and demos stay deterministic and offline. The seam for a fine-tuned ONNX classifier is that interface — onnxruntime-node is deliberately not a dependency, it does not fit in a Vercel function.

Public data

Same four sources, same severity ordering, same verdicts. OpenStreetMap via Nominatim and Overpass; NCA, Kenya Gazette and EBK behind CSV-backed adapters whose acquisition paths are documented honestly in lib/data/registers.ts. Fetches are cached in Upstash Redis or Vercel KV when KV_REST_API_URL is set, on disk under fixtures/cache otherwise, keyed identically to the reference (SHA-256 of URL plus body) — the committed fixtures carried over unmodified. Offline is the default; npm run warm fetches live and npm run warm -- --seed writes placeholder fixtures. Rate limits and the contact User-Agent are enforced; Nominatim rejects anonymous clients.

M-Pesa

/api/deposit sends the Daraja STK push and creates the pending payment row keyed on the returned CheckoutRequestID before responding — the callback can arrive first. AccountReference is capped at 12 alphanumeric characters and is not echoed back, so it carries nothing load-bearing; the CheckoutRequestID is the only join key. /api/mpesa/callback is idempotent: Safaricom retries, and only a row still in pending is acted on. A confirmed payment mints the claim on chain via depositFor from the settlement wallet; a failed chain write is recorded on the row for replay, never dropped.

Deviations from the reference, all deliberate

  • The reference's failures = zip(notes, checks.values()) silently dropped stage-failure reasons from rejection summaries whenever the novelty check passed, because a passing novelty check adds no note. The port pairs notes with their own checks; the reference's own specification — a rejection summary names only what failed — describes the corrected behaviour. No test assertion distinguishes the two.
  • bit_count(phash # $1) as specified does not exist in Postgres — bit_count is defined for bit and bytea, not bigint. The comparison is bit_count((phash # $2)::bit(64)), asserted against real Postgres (PGlite) in the tests.
  • The demo console submits real uploads instead of canned fraud scenarios, and there is no /api/reset — on a persistent chain, reset means redeploying the contract and pointing ESCROW_ADDRESS at it.

Left out, and why

  • ONNX stage classification: the interface seam exists, the runtime does not fit a Vercel function. Wire it behind StageClassifier when there is a labelled dataset worth training on.
  • IPFS pinning, permissioned L1, eERC confidential balances, KYC: not started in the reference and not started here.
  • Managed wallets are seed-derived. Production custody is a KMS or an embedded-wallet provider behind buyerAccount().

Personal data

Buyer phone numbers and identity live in Postgres, never on chain — there is no erasure remedy against a blockchain and this is personal data under the Data Protection Act 2019. The wallet addresses on chain are pseudonymous, but the address-to-person linkage in the buyers table makes them identifiable in combination, so that table is inside the same compliance boundary as the phone numbers. Register with the ODPC and document a lawful basis before holding register or director data at scale.

About

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages