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:
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.
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:
One engine handles three deal types:
| Type | What it models | Money shape |
|---|
| Product | Goods, unit price × quantity | Usually one milestone |
| Milestone project | Work delivered in stages | Multiple milestones, each with its own hold |
| Conditional | Release tied to a stated condition | Single 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.