
Riddo
Founding Engineer Go, TypeScript, PostgreSQL, Redis, React | 2025 to present
TL;DR
I built the systems behind Riddo, an exchange where people turn crypto and gift cards into naira in their bank account. The work came down to four things: a chain gateway, a fiat pipeline, on-device authentication, and monitoring that tells us when money stops moving.
Most of it is about what happens on a bad day. A bank call that times out. A message that arrives twice. A chain that reports a deposit and then reorganises. The job is to make sure none of that turns into lost money.
The problem
- Two systems, two ideas of the truth. Bank rails are slow and noisy. Chains confirm late and sometimes change their mind. The user sees one balance, so someone has to hold those two worlds together.
- Trust is the product. One late payout, one duplicate, one amount short by a rounding error, and the user is gone. There is no next release for a missing withdrawal.
- Small team, real money. No compliance department to catch us and no budget for enterprise key management. The safety had to live in the code and in how we worked.
What I built
The books. An append-only, double-entry ledger with reserve and asset accounts, and four entry types: debit, credit, reserve and reversal. A balance is never edited in place. Every balance is derived from the entries that produced it, so when a number looks wrong we can point at the exact movement that caused it.
The fiat pipeline. Money leaves our systems and lands in a user's bank account. I built that path and the checks around it: a durable job for every payout, an idempotency key on every request that can be retried, and a balance hold taken before anything is sent and released if it fails.
The chain gateway. A separate service in TypeScript (Hono, SQLite) that sits between Riddo and the blockchains. Riddo asks it for an address and gets told when money arrives. Riddo never talks to the chain provider and never holds a key.
On-device authentication. A trade is authorised with a PIN that stays on the phone. The key pair lives in the phone's Secure Enclave, the server only holds the public half, and every admin action is gated by roles and recorded in an audit log.
Monitoring. Sentry with personal data scrubbed, one structured log line per event, request ids that follow a call through the system, liveness and readiness checks, and an ops console for stuck jobs and key service logs.
The front door. The customer app in React and TypeScript: a wallet abstraction that hides seed phrases, per-network flows for Tron, Bitcoin and Ethereum, the admin permissions screens, and the payout view.
Deep dive: the fiat pipeline
The first version called the bank provider inside the request. It worked until it did not. A slow provider meant a hung request, a retry meant a second call, and the user had no way to tell whether their money had moved.
So a payout became a durable job. The job carries its own idempotency key, which means a retry is recognised as the same payout and not a new one. A callback from the provider updates the state of the job instead of starting a payment. A balance hold is taken before the transfer fires, and released if the transfer fails.
That still leaves the case that hurts most: the payout that went out but whose callback never arrived. Nothing in the queue knows about it, and the user is waiting.
So reconciliation runs against the provider's own numbers. It compares the provider's reserve balance to our books and sweeps payouts that are expected but unheard from. Money either lands, or it gets found.
We also brought in an independent security firm to attack the platform and check it against policy, and I drove the remediation through to production. Alongside it I swept the money paths myself. Three kinds of bug came out: replayable withdrawal intents, two workers believing they owned the same payout, and provider errors handled as if the call had succeeded. Each fix shipped with an integration test that fails without it.
Deep dive: the chain gateway
Riddo's riskiest surface is the chain. A wallet key in the wrong place is a total loss, so I kept the key material and the chain provider out of the main application entirely.
The gateway is its own service, with its own database, deployed on a private network. Its job is narrow: hand out deposit addresses, watch for incoming money, and tell Riddo what happened.
It never generates or stores a mnemonic. A ceremony on the separate key management host generates the wallet, then posts only the public half to the gateway. Before storing anything, the gateway derives index 0 from that public key and compares it to the address the key host says it controls. If the two disagree, it returns 422 and stores nothing.
One key for users, one for treasury. Each chain has one active hot key that derives user deposit addresses, and one master key used only for treasury movements.
Tron needs a second step. A Tron address does not exist on chain until it is activated, so a new address starts as pending. A job checks the master balance, sends a small activation transfer, waits for the signature and then for 19 blocks, and only then marks the address active. Ethereum and Bitcoin addresses are usable immediately.
Deposits arrive through a door with a lock. The chain provider posts an alert for every address and every treasury wallet. The gateway checks the path secret and the signature, stores the raw body before parsing it, and only then hands it to the chain adapter. One payload becomes one event, and a repeat is dropped.
It tells Riddo, and does not assume it was heard. Every event is signed and delivered at least once, from an outbox with retries. Riddo can also pull the same facts on demand, which is what reconciliation uses.
It refuses to run in the wrong place. Each database records which network it belongs to. If the process and the file disagree, the gateway does not boot.
The structure is enforced, not documented. A source file over 250 lines fails the build. SQL is only allowed in repository files. One module cannot reach into another module's internals. The Docker build runs that check before the type check.
The scope was honest: deposits, balances and treasury movements came first. Transfers and withdrawals still return 501. Solana is parked because the key host cannot derive Solana addresses by index.
Deep dive: on-device authentication
A PIN sent over the network is a PIN that can be stolen. Device-bound authentication puts a key pair in the phone's Secure Enclave. The private key never leaves the device and the server only holds the public half. A trade is authorised by signing a challenge that the server issued, unlocked locally by the user's PIN.
Recovery is the hard case, because a new phone has no key pair. Re-provisioning is gated on checks the user can pass, instead of a secret they have to remember.
Admin actions sit behind role-based permissions with an audit log. Sign-in uses two-factor authentication, and stored passwords use argon2id.
Deep dive: monitoring
Money systems fail in silence by default. A job stops, a webhook does not arrive, and the first signal is a user complaint.
The app reports unhandled errors to Sentry with the request id and route attached, and refuses to send headers, bodies or query strings, so personal data stays out of the error tracker. Every application event writes one JSON line, which makes a single payout traceable end to end.
Health checks are split in two. One proves the process is alive without touching the database. The other proves the database is reachable, so a deploy can be held back when it is not.
The gateway logs its key service into the same console, and the console exposes stuck jobs and failed deliveries with a retry button. Actions are POST only, protected against cross-site requests, and written to an audit log, and the browser never receives the gateway's API key.
Outcomes
- A payout lands once. Retries, missing callbacks and provider timeouts do not create a second payment.
- A gateway that has never held a key. The service that talks to blockchains can be compromised without exposing a wallet.
- Books you can prove. Every balance traces to entries instead of an update statement.
- No funds lost or double-paid across concurrent payouts, through the audit and its remediation.
- A working standard the team still runs on: the architecture map, the decision log, and tests that fail without the fix.
- Website: https://www.riddohq.com
Money systems are judged on their bad days. The design question is what the system does when the bank is slow, the callback is missing, or the same message shows up again.