Skip to content

Repository files navigation

Off-Plan Escrow

An escrow system for off-plan construction.

A buyer’s funds stay locked until there is enough construction evidence to verify a milestone. Each milestone is reviewed by three independent attesters, and any two of them must approve it before the funds are released.

If the developer stops the project, anything that has not already been released is refunded to buyers on a pro-rata basis.

This is a TypeScript port of the original Python reference implementation. The Python version is preserved in git history under the Add Python reference implementation commit.

The application runs on Next.js and Vercel, with Foundry handling the contracts and Postgres + Drizzle handling application data.

All 63 assertions from the reference implementation have passing equivalents. The escrow tests run with Foundry, while the evidence and data tests run with Vitest and work completely offline.

Project structure

contracts/src/PropertyEscrow.sol
  Escrow logic, attestations, and refunds.

contracts/src/MockKES.sol
  KES-pegged test token with 2 decimals.

contracts/test/PropertyEscrow.t.sol
  Four money-flow scenarios with 22 assertions.

contracts/script/Deploy.s.sol
  Contract deployment for Fuji or Anvil.

lib/evidence/
  EXIF, geofence, timestamp, perceptual hash, and stage checks.

lib/data/
  Cached data fetching, Nominatim/Overpass integration, and register adapters.

lib/db/
  Drizzle schema and database migrations.

lib/chain.ts
  viem clients, ABIs, and managed buyer accounts.

lib/daraja.ts
  M-Pesa STK push and callback schemas.

app/api/
  Deposit, M-Pesa callbacks, evidence, attestation,
  corroboration, state, stall and refund endpoints.

app/page.tsx
  The drawdown register console.

fixtures/
  Offline cache and register CSVs committed to the repository.

tests/
  Vitest suites covering evidence, data, and storage.

Running the tests

Start by installing the dependencies and the Foundry submodule:

git submodule update --init
npm install

Then run the tests:

npm test
npm run test: contracts

npm test runs the 48 application-level assertions completely offline.

npm run test: contracts runs the 22 Foundry assertions for the escrow contract.

Running the application locally

The full application needs a blockchain, a database, and the required environment variables.

Start with:

cp .env.example .env

Fill in the required keys and contract addresses.

Local chain

Start Anvil and deploy the contracts:

anvil &
cd contracts && forge script script/Deploy.s.sol \
  --rpc-url http://127.0.0.1:8545 --broadcast

Take the addresses printed by the deployment and add them to .env together with:

RPC_URL=http://127.0.0.1:8545

Database

Run the migrations:

npm run db: migrate

Then start Next.js:

npm run dev

The console will be available at:

http://localhost:3000

Fuji

For Fuji, deploy against:

https://api.avax-test.network/ext/bc/C/rpc

Use three separately funded accounts when testing attestations. That way, the two-of-three approval model is actually being exercised rather than having one account sign multiple times.

Evidence verification

Every submitted image goes through four checks.

  1. Location EXIF GPS coordinates must fall inside the project's site geofence.

  2. Timestamp The capture time must fall within the reporting window. Future timestamps are rejected separately from stale submissions.

  3. Image similarity A perceptual hash is compared against previously accepted submissions. Images within a Hamming distance of 6 are rejected.

  4. Construction stage The classified stage must match the milestone being claimed, with a minimum confidence of 0.6.

The checks follow the same semantics and rejection wording as the reference implementation.

The perceptual hash is implemented in the project rather than pulled in as a dependency. Images are converted to 32x32 grayscale using sharp, processed with DCT-II, reduced to the top-left 8x8 coefficient block, thresholded against the median, and packed into a 64-bit value.

Those hashes are stored in Postgres as BIGINT. The Hamming-distance comparison happens in SQL, which means the duplicate check works consistently across serverless instances.

The submission bundle hash is also byte-compatible with the Python reference. The tests include a fixed vector generated by the original implementation, along with checks for stability and sensitivity to each input.

Stage classification

Stage classification sits behind a single StageClassifier interface.

The default implementation calls a vision-language model using fetch and validates the response with Zod. It uses ANTHROPIC_API_KEY and has a server-side refusal fallback.

There is also a sidecar backend that reads a label file uploaded with the image. This keeps tests and demos deterministic and fully offline.

The same interface leaves room for a fine-tuned ONNX classifier later. onnxruntime-node is intentionally not included because it does not fit well within a Vercel function.

Public data

The application uses the same four data sources as the reference implementation, with the same severity ordering and verdicts.

For public geographic data, it uses:

  • OpenStreetMap
  • Nominatim
  • Overpass

The NCA, Kenya Gazette, and EBK data are accessed through CSV-backed adapters. Their acquisition paths are documented in lib/data/registers.ts.

Offline mode is the default.

When KV_REST_API_URL is available, fetched data is cached in Upstash Redis or Vercel KV. Otherwise, the cache is stored locally under fixtures/cache.

Cache keys match the reference implementation: SHA-256 of the URL and request body.

The committed fixtures are carried over unchanged.

To fetch live data:

npm run warm

To generate placeholder fixtures:

npm run warm -- --seed

Rate limits and the required contact User-Agent are enforced. Nominatim requests are not made anonymously.

M-Pesa

The payment flow uses Safaricom Daraja.

/api/deposit starts the STK push and creates a pending payment record using the returned CheckoutRequestID before responding.

This matters because the M-Pesa callback can arrive before the original request has finished processing.

AccountReference is limited to 12 alphanumeric characters and is not used as a data-bearing identifier. The CheckoutRequestID is the only join key.

The callback endpoint is idempotent. Safaricom can retry callbacks, but only a payment still marked as pending is processed.

Once payment is confirmed, the settlement wallet calls depositFor to mint the claim on chain.

If the chain transaction fails, the failure is recorded against the payment row so it can be replayed later. It is never silently discarded.

Who may do what

There are two kinds of session. Both are HMAC-signed cookies under SESSION_SECRET.

A buyer or sender proves their M-Pesa number with a six-digit code: /api/auth/otp sends it and /api/auth/verify checks it. Codes live five minutes, allow five attempts, and are issued at most once a minute per number. Registering, paying and approving a milestone then act for that number only; no request body carries a phone.

Codes go out through Africa's Talking when AT_USERNAME and AT_API_KEY are set. Without them the code is written to the server log, which is enough for a demo.

An operator unlocks the register with OPERATOR_SECRET, either as Authorization: Bearer on the API or through /api/auth/operator for a cookie. Submitting evidence, countersigning, stalling, refunding, creating a project and replaying payments all require it.

Reads are open: /api/state, /api/projects, /api/listings, /api/trustees, /api/health and /api/activities?address=.

Delivering codes needs a provider: Resend for email (RESEND_API_KEY, EMAIL_FROM) and Africa's Talking for SMS (AT_USERNAME, AT_API_KEY). Without one the code goes to the server log. For a demonstration with neither, OTP_TEST_CODE sets a fixed six-digit code that is accepted on any channel with no provider; it is a deliberate back door for demos and must be unset before anything real runs on the deployment.

Accounts, roles and the registry

Buyers sign in with the M-Pesa number they pay from and give their name and email when they commit, which opens an account on that number if they had none, so the commitment is a person with an address from the first shilling. An email given that way is contact detail until a code sent to it is typed back on the account page. Sellers, developers, companies and senders abroad sign in with an email address, then add the number they will pay from. Either way the code is one-time, and the subject that was proven is the identity: an Avalanche address is derived from it with the master seed, the same way buyer wallets always were, and registered with the role in the DatumRegistry contract. The address is on chain; the email, number and name are not.

Sellers, developers, companies and senders pay a one-off fee (LISTING_FEE_KES, KES 200 by default) by M-Pesa prompt to the number they added. The confirmation comes through the same callback as a deposit, marks the account paid, and proves the number, because only that handset could have approved the prompt. Until it is paid, a seller cannot post and a sender cannot commit.

A listing is posted with photographs, stored downscaled in Postgres because a serverless function has no disk, and waits as "awaiting verification". Datum staff reach out to the owner at the email and number on the account, verify them and what they posted, and approve from the review desk. Approval records the identity verdict on chain against the listing's content hash, posts the listing to the registry, which refuses it unless that verdict is there, deploys the escrow with the listing's milestones, the owner's address as payee and the assigned trustee as attester 1, marks the listing live, and opens it to buyers. The self-service identity upload still exists as an API but the product path is staff verification.

Every action anyone takes is written to the registry as a hash keyed to the actor's address: account opened, fee requested and paid, listing posted and approved, commitment, deposit requested and confirmed, evidence, countersign, approval, decline, project created, stalled, refunded. The readable record stays in Postgres beside the hash and the transaction. A write that fails is kept with its error and retried by the replay job. The platform wallet pays the gas, which is free on Fuji and a real cost anywhere else.

Concurrency note: the platform wallet signs every registry write. Two requests landing in the same second can race on the nonce; the loser is recorded without a transaction and replayed. A queue in front of the wallet is the production fix.

Build requests

The other way a project starts. An owner, at home or abroad, asks Datum to build on their plot: what, where, and a budget. Creating the request sends an M-Pesa prompt for the initialisation deposit, ten percent of the budget by default (BUILD_INITIAL_DEPOSIT_PERCENT), which is recorded against the request because no escrow exists yet. Staff then assign a verified builder and a trustee, set the price and the milestones, and propose; the proposal is hashed and that hash is what both sides sign, each from their own account, each signature an activity on chain from their own address. The second signature deploys the escrow with the builder as payee and the trustee as attester 1, moves the initial deposit into it as the owner's first claim, and the build is active: the owner pays the rest in instalments, the builder submits photographs, the trustee countersigns, and each milestone releases its share.

Staff can record a deposit settled outside M-Pesa with a reason, which goes on chain. Proposing again replaces the agreement and clears both signatures.

Projects

A project is a database row, not a deployment.

POST /api/projects deploys a settlement token and an escrow carrying that project's milestones and attesters, mints the settlement float, and writes the addresses to the row. The register page has the form.

Every other route takes ?project=<id>. When exactly one project exists the parameter can be omitted, so a single-site install and the curl examples keep working.

Setting a sender number at creation makes it a remittance build: that number's managed wallet becomes attester 1 and is given a little AVAX so its approvals pay their own gas.

Operations

/api/ops/replay re-checks every instalment left verifying, pending for more than three minutes, or failed after a chain write. Each is queried against Safaricom's status endpoint and credited if it was paid.

Vercel's cron calls it once a day, which is the Hobby plan's limit, with CRON_SECRET. An operator can call it at any time and should after any Safaricom outage.

/api/health reports the database, the RPC and each project's contract separately, so an alert names what fell over.

Differences from the reference implementation

There are a few intentional differences.

Rejection summaries

The Python reference used:

failures = zip(notes, checks.values())

That could cause stage-failure reasons to disappear from the rejection summary when the novelty check passed.

The TypeScript implementation pairs each note with its own check instead.

The result is simpler and matches the intended specification: a rejection summary should tell you exactly which checks failed and why.

No existing test distinguishes the two behaviours.

Postgres bit counting

Postgres does not provide bit_count for BIGINT in the way the reference expression expects.

The implementation therefore uses:

bit_count((phash # $2)::bit(64))

The behaviour is tested against real Postgres through PGlite.

What is intentionally not included

ONNX stage classification

The StageClassifier interface is already in place, but the runtime classifier is not.

There is no reason to ship a model before there is a labelled dataset worth training on, and onnxruntime-node is not a good fit for the current Vercel deployment.

Other features

The following are outside the current scope:

  • IPFS pinning
  • Permissioned L1
  • eERC confidential balances
  • KYC

None of these were part of the original reference implementation either.

Managed wallets

The current managed wallets are seed-derived.

For production, custody should move behind a KMS or embedded-wallet provider through buyerAccount().

Settlement

Settlement is a mock shilling token on Fuji, minted to the platform wallet as float.

Real money would need a licensed e-money or trust arrangement behind depositFor, and a payout rail from the developer's address back to a bank account. Neither is something this repository can supply.

Production M-Pesa

M-Pesa runs against the Daraja sandbox. Going live is a Safaricom onboarding with a paybill, not a code change.

Authoritative records

The public-record check reads cached snapshots of the NCA register, the Gazette and EBK projects. Live fetching is wired but rate-limited.

None of those registers is authoritative for land title. That stays a Lands Registry search done by a person.

Regulatory standing

Holding buyers' money in escrow is regulated activity in Kenya. This is an MVP for demonstration, not a licensed service.

Pages

  • / is the front door: what Datum does, live figures, and a card per role that sends buyers to the listings, sellers, developers and companies to open an account with that role preselected, trustees to their desk, and the operator to the register.
  • /listings shows what is live. Every listing there has a verified owner, an assigned trustee and a deployed escrow.
  • /account is where a person proves their email or number, opens their account, adds the number they pay from, pays the fee, posts with photographs, and waits for staff to verify them.
  • /buy?project=<id> is where a buyer or sender commits, pays in, and where the holder of the second signature approves a milestone.
  • /trace/<address> is the public record of everything done from one address: kinds, times, record hashes and transactions, with the payload kept private. Every account page links to its own.
  • /build is where an owner asks for a build, pays the initial deposit, and signs the agreement; builders sign there too.
  • /register?project=<id> is the drawdown register: evidence, countersigning, public-record checks, the buyer ledger, and the operator's review panels.

Demo console

The demo console uses real image uploads rather than canned fraud scenarios.

There is deliberately no /api/reset.

On a persistent chain, resetting the system means creating a new project, which deploys a fresh escrow.

Team

Daniel Mwihoti and Soni Mwangi, founders. The pitch deck and brochure are in pitch/.

Personal data

Buyer phone numbers and identity information are stored in Postgres and never written to the blockchain.

That distinction matters because blockchain data cannot simply be erased later.

Wallet addresses are pseudonymous on chain, but once an address is connected to a buyer record in Postgres, the two can be used together to identify the person.

For that reason, the buyer table and related identity data should be treated as part of the same compliance boundary as the phone numbers.

Before using the system with real register or director data at scale, the production deployment should address the requirements of Kenya's Data Protection Act 2019, including registration with the ODPC where applicable and documenting the lawful basis for processing the data.

About

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages