Ammar Mohammed
Resume
Back to work

Case study

Adulis Escrow

Mobile escrow for high-value trades in Ethiopia — funds lock until contract terms are met.

In production · Android
Flutter
GetX
Dio
WebSockets
Chapa
Clean Architecture

The problem

High-value trades in Ethiopia still mostly run on trust and bank transfers. Pay first and the seller may disappear. Ship first and the buyer may never pay. There's no neutral party holding the money while both sides deliver.

Adulis Escrow is that neutral party: funds lock in escrow until contract terms are met. Individuals and businesses use it for product sales, milestone-based projects, and condition-based deals. Disputes freeze the hold for admin review. Competitive bidding — both private and government auctions — can spawn a deal automatically when a winner confirms.

The mobile app is where buyers and sellers actually live — not an internal admin tool.

What I built

I owned the Android-first Flutter client end to end, working against a backend built and maintained by a separate team:

Adulis Escrow home dashboard
Adulis Escrow login screen
Individual vs business signup selector
Home, sign-in, and account type
  • Auth & identity — individual vs. business signup, OTP verification, session refresh, business-approval gating before bidding or dealing, and biometric app lock. A single account can be the initiator on one deal and the counterparty on another.
  • Deal wizard — a controlled multi-step flow: pick counterparty and deal type → fill the product, milestone, or conditional form → choose a contract template → capture identity → review → submit. Deal detail only shows actions valid for the viewer's role — fund, request release, release, dispute, and so on.
  • Wallet — available vs. locked balances, Chapa checkout, manual deposit with receipt upload, CBE withdrawal, and transaction history.
  • Live bidding — browse private and government auctions, place bids, pay for document access, and confirm a win (which can spawn a new deal). The live room prefers a realtime connection and falls back to polling when the network drops.
  • App shell — home dashboard, notifications, profile and settings, English and Amharic language switching.
Live auction bid detail screen
Place a new bid screen
English and Amharic language settings
Live bidding and language switching

Architecture

The client's one hard rule: display amounts, never decide them. Balances, the platform fee, bid bonds, and escrow holds are all computed and owned by the server. The app maps API responses into domain models through use cases, with explicit failure handling on the money and auth paths.

System architecture

System architecture. Flow: App UI → Domain use cases; Domain use cases → Repositories; Repositories → HTTP + session refresh; App UI → Auction realtime client; App UI → Biometric lock; HTTP + session refresh → Secure token storage; HTTP + session refresh → Auth API (authenticated requests); HTTP + session refresh → Deals API; HTTP + session refresh → Wallet API; HTTP + session refresh → Bidding API; HTTP + session refresh → Notifications API; Auction realtime client → Realtime + cache (ticket then live stream); Deals API → Escrow holds; Wallet API → Wallet ledger; Escrow holds → Wallet ledger; Wallet API → Chapa; Wallet API → CBE withdraw; Deals API → PostgreSQL; Wallet API → PostgreSQL; Auth API → PostgreSQL; Deals API → Object storage; Wallet API → Object storage; Background jobs → Redis.

Identity is snapshotted onto the deal at create and accept time — name, address, business documents, Fayda FIN, ID images. If someone edits their profile later, it must not silently rewrite who agreed to the contract. That's why KYC capture is a step in the deal wizard, not just a settings-page form.

The deal lifecycle

A deal isn't a form submission — it's a lifecycle that both parties and an admin can act on at different points:

Deal lifecycle

Deal lifecycle. Flow: Invited → Changes requested (counterparty asks changes); Invited → Rejected (counterparty declines); Invited → Awaiting admin (both sides agreed + KYC); Awaiting admin → Awaiting funds (admin approves); Awaiting admin → Admin rejected (admin rejects); Awaiting funds → Active (initiator funds · escrow locked); Active → Disputed (dispute · funds frozen); Active → Completed (last milestone released); Active → Cancelled (cancel · unreleased holds refunded).

One engine handles three deal types:

TypeWhat it modelsMoney shape
ProductGoods, unit price × quantityUsually one milestone
Milestone projectWork delivered in stagesMultiple milestones, each with its own hold
ConditionalRelease tied to a stated conditionSingle hold until condition + release

The money path for each milestone: the initiator funds → escrow locks the amount → the deal becomes active. The counterparty can request release. The initiator releases → a platform fee routes to the company wallet, and the net amount goes to the seller. The last milestone released closes the deal. A dispute freezes both the deal and the milestone. Cancelling before release refunds any unreleased holds to the initiator.

Because the same person can be a buyer on one deal and a seller on another, every action on the detail screen is role-gated: only the initiator can fund or release, only the counterparty can request release, and either side can dispute.

The hard problem: live money and live auctions on an unreliable connection

Two places where the obvious client implementation is wrong.

Checkout is not settlement. Chapa opens a checkout page inside the app. Closing that page is not proof that money moved. Instead, the app shows a processing screen and waits until the wallet balance actually updates — the same pattern applies after fund and release actions. The client never invents a local balance to show optimistically; the backend ledger is the single source of truth, and the app is only ever a view onto it. A fast "Paid" toast is the wrong product when the money underneath isn't confirmed yet.

Live auctions drop on mobile networks. The auction room streams live state over a realtime connection. Auth uses a single-use ticket, so on app pause the socket is torn down; on resume, the app resyncs current bids, mints a fresh ticket, and reconnects. If the socket never comes back up, the room silently falls back to short-interval polling so a bidder isn't staring at a stale price. Placing a bid always goes through the API regardless of socket state, with a minimum-increment check in ETB before the request is sent.

That combination — server-authoritative money paired with a UI that stays live despite an unreliable connection — is the part of this project I'm most proud of.

Other decisions worth calling out

  • Clean Architecture where money is involved. Auth, deals, wallet, bidding, and notifications follow a strict data → domain → presentation split. Home and settings are intentionally thinner UI-only layers — not every screen needs the same rigor.
  • Single-flight session refresh. A burst of expired requests doesn't trigger parallel refresh calls; one refresh happens and the originals retry once against the new token.
  • Built for Ethiopian payment rails. Chapa for deposits, an admin-approved manual receipt path as a fallback, CBE for withdrawals. Fayda FIN is required on every deal. Bidding is split into private and government domains with different rules. English and Amharic are supported in the shell.
  • Bid bonds reuse the escrow primitive. A bid bond (5% of the bid) uses the same underlying hold mechanism as a milestone. Auctions and deals share one money engine, which kept the wallet logic from forking into two parallel systems.