Building a secure fintech backend.
Novunt is a goal-based staking platform — but strip away the product language and the backend is a custody system: wallet balances, crypto settlement, an internal ledger, and admin-gated payouts. This is the record of what it took to move real money correctly.
Ordinary web-app bugs throw errors. Fintech bugs silently do the wrong thing correctly, twice.
A staking platform holds custodial wallet balances, settles crypto deposits and withdrawals through a third-party processor, and pays scheduled returns against an internal ledger. Every one of those is a place where a race condition or an unenforced boundary turns into money gained or lost — not a stack trace.
Deposits are confirmed through two independent paths — a processor webhook and a polling fallback — so a race between them can credit the same deposit twice if nothing stops it.
Withdrawal requests move real funds out. A balance deducted twice, or approved without the right gate, is not a data-integrity bug — it's a loss.
A wallet balance is just a number until something can prove it's the right number. Without a ledger, drift between what the system credited and what a user actually has is invisible until someone complains.
Make correctness structural, not procedural.
A check-then-act guard can always lose a race. The fixes that held were the ones that made the database itself the referee — a single atomic write that can only succeed once, an immutable ledger the application cannot quietly edit, a lock that survives a process restart. Three pillars carry that idea through the backend.
Atomic claims
Deposit confirmation and backup-code redemption are single conditional database writes — exactly one caller can ever win.
Immutable ledger
Double-entry, hash-verified, append-only. The live balance is a cache; the ledger is the truth.
Threshold custody
Withdrawals scale from instant to admin-approved to double-confirmed purely by amount, configurable at runtime.
Two confirmation paths for reliability, one atomic gate to stay correct.
NOWPayments Webhook Polling Job (5 min cron)
│ │
└─────────────┬──────────────┘
▼
Atomic claim: updateOne({status ≠ confirmed})
│
modifiedCount === 1 ?
│ │
yes no
│ │
credit wallet skip — already claimed
│
▼
Double-entry ledger write (debit + credit)| Layer | Technology | Why it's there |
|---|---|---|
| API | Node.js · Express · TypeScript | Request handling, validation middleware, RBAC-gated admin routes |
| Data | MongoDB · Mongoose | Wallets, transactions, ledger entries, unique-transaction-ID dedup indexes |
| Settlement | NOWPayments (webhook + poller) | Crypto deposit/withdrawal processing with a dual confirmation path |
| Realtime | Socket.IO | Live balance and transaction status updates to the client |
| Scheduling | node-cron | Deposit polling, ROS distribution, rank checks, reconciliation |
| Auth | JWT · Speakeasy TOTP · WebAuthn | Session auth, 2FA on every admin role, biometric device support |
The bug that mattered most: paying someone twice for one deposit.
Crypto deposits are confirmed two ways — a processor webhook, and a polling job that re-checks pending deposits as a safety net for webhooks that get dropped or arrive late. Both paths originally shared the same logic: check whether the deposit is already confirmed, and if not, confirm it and credit the wallet. Read, then write.
Check-then-act
If the webhook and the poller fired close enough together, both could read “not yet confirmed” before either had written back — and both would credit the wallet. Same deposit, two credits.
Atomic conditional write
Confirmation became a single database update: flip status to confirmed only if it isn't already confirmed, and credit the wallet only if that specific write is the one that changed something. Exactly one competing process can ever win.
Self-inflicted redundancy
The polling job existed because an earlier fix stopped the processor from retrying dropped webhooks. Solving that reliability problem is what introduced the correctness problem.
A second, quieter bug lived in the same territory: the poller only re-checked deposits from the last 24 hours, so a deposit that confirmed on-chain after that window fell out of range and was never credited. The recovery window widened to 7 daysso a slow confirmation doesn't become a lost deposit either.
Idempotency, in layers.
"Idempotency" sounds like one feature. In practice it became three separate mechanisms, each answering a different version of the same question — has this exact operation already happened?
Mutation endpoints accept an idempotency key. A duplicate in-flight request is rejected; a duplicate after completion replays the original response.
Every processor operation — invoice, payment, blockchain tx hash — is recorded against a unique index, so the same external transaction can never become two internal ones.
Every inbound webhook is checked against “has this payment ID already been processed” before it touches a balance, closing the replay-attack angle.
No single layer would have been enough alone — each is defending against a different actor: a double-click, a bug in application logic, or a resent payload.
An immutable, double-entry ledger.
Early on, a wallet balance was just a number, incremented and decremented in place — fine until you need to answer why a balance is what it is, or catch drift between what the system credited and what a user's transaction history says they should have.
| Rule | What it enforces |
|---|---|
| No mutation after write | A save on an already-persisted entry throws — enforced in the schema, not by convention. |
| Every entry is two-sided | A debit and a credit are written as separate documents for every financial event, never a single balance delta. |
| Every entry is hashed | A verification hash over the entry's core fields lets tampering be detected by re-hashing and comparing. |
| Balance is a cache | A scheduled job recomputes each wallet's balance from the ledger and flags drift against the live value. |
This is the standard fintech answer to trust but verify: the live balance is a cache, the ledger is the truth, and a scheduled reconciliation job checks the cache against the truth rather than assuming they never diverge.
Withdrawals: where mistakes are losses, not reconciliation tasks.
Deposits are asymmetric — worst case, the system is wrong about money coming in. Withdrawals are money leaving, so every mistake is a loss. An early version approved all withdrawals instantly, trusting upstream fraud checks alone. That trust outran what those checks had actually been tested against, and the surface was rebuilt around amount-based thresholds.
Instant. Existing fraud and rate checks are the only gate — threshold configurable at runtime, not hardcoded.
Queued for manual admin approval. Balance is deducted atomically at request time, with a refund path if rejected.
A separate confirmation flag is required from the approving admin — one click can't clear a large payout alone.
Underneath every balance-touching request sits a stack of four concurrency layers, each guarding against a different scope of race:
Request fingerprint
An in-memory hash of user, amount, and destination rejects an identical request repeated within seconds.
Operation lock
A short-lived in-memory lock scoped to the specific operation, guarding against same-process double-submits.
Distributed lock
A MongoDB-backed lock with a unique index and TTL expiry — survives a process restart, guarding cross-instance races.
Optimistic version check
The final balance write carries a document version; a concurrent write elsewhere fails the check rather than silently overwriting.
Why these choices hold together.
A missed race condition isn't a bug ticket. It's someone's money.
Redundancy for availability and correctness under concurrency are different goals. Every fallback path added for reliability — a poller, a retry, a second confirmation channel — was audited for the new race it could introduce, not just the gap it closed.
A single conditional database write beats a check-then-act guard every time two processes can run concurrently.
The wallet balance is a materialized cache; the immutable ledger is what reconciliation trusts.
Custody risk scales with amount — instant, approved, and double-confirmed are three different postures, not one.
Fingerprint, lock, distributed lock, and version check each cover a scope the others don't.