StableXchange: I tried to "just move money" and accidentally built a distributed system.
Project Deep Dive
"Let people pay in INR and get stablecoins. Or send stablecoins and have INR land in someone's bank account."
That's it. That's the pitch. And then reality showed up with a baseball bat labeled: state, retries, webhooks, idempotency, expiry, ledger, and the all-time classic: edge cases.
What exists today (in code)
StableXchange is a Go service that exposes a gRPC API, and also exposes REST via grpc-gateway. It supports flows around:
- FX quotes (base rate + margin + fee, with expiry)
- Transfer (stablecoin → INR style transaction master creation + expected inbound tracking)
- Receive (INR → stablecoin via Razorpay initiation + webhook-driven delivery)
- Marketplace buy/sell primitives
- API key issuance
- Wallet verification via signed challenge (Ethereum-style signed message)
- API logs + stats (ClickHouse + MV)
- Kafka consumers to process payment gateway inbound and wallet inbound events
- Ledger updates to keep funds safe when things don't match expectations
Architecture in one glance
+---------------------------+
| Clients |
| gRPC or REST (gateway) |
+-------------+-------------+
|
v
+---------------------+
| StableXchange |
| Go + gRPC server |
| + grpc-gateway |
+----+----+----+------+
| | |
+---------------+ | +------------------+
| | |
v v v
+----------------+ +----------------+ +------------------+
| MongoDB | | Postgres | | ClickHouse |
| - businesses | | - masters | | - api_call_logs |
| - beneficiaries| | - quotations | | - stats via MV |
| - wallets | | - ledger | +------------------+
+----------------+ +----------------+
|
v
+----------------+
| Kafka |
| gateway.inbound|
| wallet.inbound |
+-------+--------+
|
+--------------+------------------+
| |
v v
+------------------+ +------------------+
| Razorpay Webhooks| | Wallet Service |
| (HTTP :11001) |<--gRPC--------| (CreateTx, etc) |
+------------------+ +------------------+The two money directions (the real flows)
1) INR → Stablecoin
This is the "user pays INR, we send stablecoin" direction.
Client -> Receive() -> Razorpay order created
-> user pays on Razorpay
Razorpay -> Webhook -> StableXchange
-> Kafka (gateway.payment.inbound)
Kafka consumer -> load intended tx -> call Wallet.CreateTx()
-> update master record with wallet tx idASCII state machine (how it wants to behave):
[Initiated]
|
| Razorpay webhook (captured)
v
[PG Completed] ---> (Kafka msg) ---> [Kafka Consumed]
|
| Wallet.CreateTx()
v
[Stablecoin Sent]Emotion checkpoint
This is where "payments is easy" dies. Because webhooks retry. Kafka retries. Wallet tx can fail. And every retry wants to double-send money.
2) Stablecoin → INR
This is the "stablecoin arrives on-chain, match it to intent, then settle INR" direction.
Client -> Transfer() creates master record (and expected inbound record)
User sends stablecoin -> Wallet service observes -> Kafka wallet.payment.inbound
StableXchange consumer:
- find expected inbound by (sender wallet + txHash)
- validate expiry + amount
- update master record success/cancel
- move unexpected/extra funds into Ledger
- delete expected inbound recordASCII of the matching idea (this pattern is the backbone):
Step 1: intent exists
+----------------------------+
| expected_inbound_wallet_tx |
| sender_wallet + txhash |
| amount + validity + type |
+----------------------------+
Step 2: event arrives (async)
+----------------------------+
| Kafka wallet.inbound event |
| sender_wallet, txhash, amt |
+----------------------------+
Step 3: reconcile
if match && within validity && amt ok:
mark master SUCCESS
else:
mark CANCELLED
credit ledger (funds safe)Emotion checkpoint
This part is weirdly satisfying. Because it's you saying: "I don't trust the world, so I'll build a ledger." That's an adult sentence.
The stuff that looks small but ate days
Quotes and expiry
I generate quotes with: base price from a price feed, a margin, a platform fee, and an expiry timestamp. "Here's your rate. It's valid until X." That's not just product behavior—it's defensive engineering.
Rounding: the silent killer
I ended up caring about floor/ceil at different steps depending on who sponsors the fee. You think: "Why am I spending brain cells on ceil vs floor?" …and then you remember: money.
Observability: ClickHouse was a power move
Every API call becomes a ClickHouse row. Stats become a materialized view. Dashboards stop being "later". This is what you build when you've been burned by "we need a dashboard" + "we need it fast".
War stories
The wins
- I didn't build this as a single synchronous pipeline—I used Kafka and built reconciliation patterns
- I added a ledger to safely hold funds when a tx is late, wrong amount, mismatched, or not expected
- I separated concerns across stores: MongoDB (identity-ish objects + raw events), Postgres (transactional truth + ledger), ClickHouse (analytics)
The sharp edges (still visible in code)
- Webhook signature verification is TODO—explicitly left as TODO. Honest. Also high priority.
- Request body decoding happens twice in webhook flow—works in your head, doesn't always work with real HTTP streams
- Nil channels / concurrency footguns—classic Go footgun. Very "I was moving fast" energy.
- Outbox pattern intent is there, but ordering needs hardening
- Case normalization mismatch risk in expected inbound lookup—tiny bug, huge "why didn't it match??" moment
"I'm not even building product right now. I'm wrestling entropy." Welcome to payments.
Why it's abandoned (for now)
The codebase has real bones: quotes, intents, reconciliation, event-driven delivery, a ledger, analytics, and the beginnings of operational safety.
But the project stays abandoned (for now) for a simple reason:
It was too early. Too early in the ecosystem. Too early in clarity around how the whole compliance + operations story should look end-to-end. Too early to justify the amount of engineering + legal weight required to push it safely into production.
I didn't lose interest in the problem. I just learned that timing is a feature—and sometimes it's the missing one.
If you read this and felt "yep, that's payments", then you get it.