Skip to main content
Case Study · Novunt

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.

Atomic deposit claimsLayered idempotencyImmutable ledgerThreshold-gated withdrawals
01 — Problem

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.

Settlement

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.

Custody

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.

Trust

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.

02 — Thesis

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.

03 — Architecture

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)
LayerTechnologyWhy it's there
APINode.js · Express · TypeScriptRequest handling, validation middleware, RBAC-gated admin routes
DataMongoDB · MongooseWallets, transactions, ledger entries, unique-transaction-ID dedup indexes
SettlementNOWPayments (webhook + poller)Crypto deposit/withdrawal processing with a dual confirmation path
RealtimeSocket.IOLive balance and transaction status updates to the client
Schedulingnode-cronDeposit polling, ROS distribution, rank checks, reconciliation
AuthJWT · Speakeasy TOTP · WebAuthnSession auth, 2FA on every admin role, biometric device support
04 — Deep Dive

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.

The race

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.

The fix

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.

The twist

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.

05 — Deep Dive

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?

API layer

Mutation endpoints accept an idempotency key. A duplicate in-flight request is rejected; a duplicate after completion replays the original response.

Database layer

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.

Webhook layer

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.

06 — Deep Dive

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.

RuleWhat it enforces
No mutation after writeA save on an already-persisted entry throws — enforced in the schema, not by convention.
Every entry is two-sidedA debit and a credit are written as separate documents for every financial event, never a single balance delta.
Every entry is hashedA verification hash over the entry's core fields lets tampering be detected by re-hashing and comparing.
Balance is a cacheA 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.

07 — Deep Dive

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.

< $50

Instant. Existing fraud and rate checks are the only gate — threshold configurable at runtime, not hardcoded.

≥ $50

Queued for manual admin approval. Balance is deducted atomically at request time, with a refund path if rejected.

High-value

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:

Layer 1

Request fingerprint

An in-memory hash of user, amount, and destination rejects an identical request repeated within seconds.

Layer 2

Operation lock

A short-lived in-memory lock scoped to the specific operation, guarding against same-process double-submits.

Layer 3

Distributed lock

A MongoDB-backed lock with a unique index and TTL expiry — survives a process restart, guarding cross-instance races.

Layer 4

Optimistic version check

The final balance write carries a document version; a concurrent write elsewhere fails the check rather than silently overwriting.

08 — Principle

Why these choices hold together.

Core idea

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.

Atomic over defensive

A single conditional database write beats a check-then-act guard every time two processes can run concurrently.

Ledger as truth

The wallet balance is a materialized cache; the immutable ledger is what reconciliation trusts.

Thresholds, not blanket trust

Custody risk scales with amount — instant, approved, and double-confirmed are three different postures, not one.

Defense in depth

Fingerprint, lock, distributed lock, and version check each cover a scope the others don't.

09 — By the numbers
1 write
Atomic deposit claim
3
Idempotency layers
4
Concurrency layers on withdrawal
2-sided
Every ledger entry
$50
Configurable instant-withdrawal ceiling
24h → 7d
Deposit recovery window
Building something like this? Let's talk.