← All insights

Stablecoin Settlement Reconciliation

Moving money between fiat rails and stablecoin rails sounds straightforward until you actually build it. The happy path is easy. A customer sends dollars, you mint or transfer USDC, done. But production systems live in the unhappy path. Wires arrive late. Transactions get stuck in pending. Amounts don't match. And you need to account for every cent on both sides of the bridge.

This is a walkthrough of how to build settlement reconciliation that actually works, based on systems I've built that process mid seven figures daily.

The settlement lifecycle

A fiat to stablecoin settlement goes through these stages.

  1. Intent capture. A customer or internal system requests a settlement. You record the requested amount, the source currency, the destination chain and token, and the recipient address. This creates a settlement record with status CREATED.
  2. Fiat leg. The customer sends a wire, ACH, or SEPA transfer to your omnibus bank account. Your banking partner sends a webhook or you poll their API to detect the inbound deposit. The settlement moves to FIAT_RECEIVED.
  3. Amount matching. You match the received fiat amount to the settlement intent. This is where things get messy, and I'll cover it in detail below.
  4. Crypto leg. You initiate the stablecoin transfer. For USDC, this means calling transfer on the token contract or using Circle's API to mint from reserves. The settlement moves to CRYPTO_PENDING.
  5. Confirmation. You wait for the transaction to reach sufficient block confirmations. On Ethereum, 12 confirmations is standard. On Solana, finalized commitment. The settlement moves to SETTLED.
  6. Reconciliation. An async process verifies that the fiat debit and the crypto credit balance to zero (minus fees). Any discrepancy triggers an alert.

Idempotency is not optional

Every operation in this pipeline must be idempotent. Not "should be." Must be. Here's why.

Your banking partner's webhook fires twice for the same deposit. Your blockchain node returns a timeout on the transfer call, but the transaction was actually broadcast. Your reconciliation job crashes halfway through a batch. All of these happen in production. All of them will corrupt your ledger if your operations aren't idempotent.

The pattern I use is an idempotency key derived from the settlement ID and the operation type.

async function executeSettlement(settlementId: string) {
  const idempotencyKey = `settle_crypto_${settlementId}`;

  const existing = await db.idempotencyKeys.findUnique({
    where: { key: idempotencyKey }
  });

  if (existing?.status === 'COMPLETED') {
    return existing.result;
  }

  if (existing?.status === 'IN_PROGRESS') {
    // Another process is handling this. Check if it's stale.
    if (Date.now() - existing.updatedAt > STALE_THRESHOLD_MS) {
      // Reclaim and retry
      await db.idempotencyKeys.update({
        where: { key: idempotencyKey },
        data: { status: 'IN_PROGRESS', updatedAt: new Date() }
      });
    } else {
      throw new ConcurrentOperationError(idempotencyKey);
    }
  }

  // Record intent before doing anything
  await db.idempotencyKeys.upsert({
    where: { key: idempotencyKey },
    create: { key: idempotencyKey, status: 'IN_PROGRESS' },
    update: { status: 'IN_PROGRESS', updatedAt: new Date() }
  });

  const result = await sendStablecoinTransfer(settlementId);

  await db.idempotencyKeys.update({
    where: { key: idempotencyKey },
    data: { status: 'COMPLETED', result: JSON.stringify(result) }
  });

  return result;
}

The stale threshold matters. If a process grabs the lock and then crashes, you need to be able to reclaim it. We use 5 minutes. That's long enough for any normal blockchain confirmation but short enough that a stuck settlement gets retried within a reasonable window.

Ledger design

Do not use a single balance field that you increment and decrement. I have seen this in production at companies that should know better. It causes reconciliation nightmares because you lose the audit trail.

Use an append only ledger with double entry bookkeeping. Every movement of value creates two entries. One debit, one credit. The sum of all entries for any account must equal the account's balance at all times.

CREATE TABLE ledger_entries (
  id            BIGSERIAL PRIMARY KEY,
  settlement_id UUID NOT NULL REFERENCES settlements(id),
  account_id    TEXT NOT NULL,
  entry_type    TEXT NOT NULL CHECK (entry_type IN ('DEBIT', 'CREDIT')),
  amount        NUMERIC(28, 8) NOT NULL CHECK (amount > 0),
  currency      TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  metadata      JSONB
);

-- Every settlement produces exactly one debit and one credit
-- Fiat received: DEBIT customer_fiat, CREDIT omnibus_fiat
-- Crypto sent: DEBIT omnibus_crypto, CREDIT customer_crypto

Use NUMERIC(28, 8) for amounts. Never use floats. USDC has 6 decimals. USDT has 6. DAI has 18. You need a type that handles all of these without rounding errors. 28 digits of precision with 8 decimal places covers everything up to about $10 trillion, which should be enough for a while.

The metadata column is for storing things like transaction hashes, wire reference numbers, and exchange rates at the time of settlement. You'll need all of this for audits.

Amount matching and partial settlements

In a perfect world, a customer says they'll send $10,000 and $10,000 arrives. In reality, here's what happens.

  • The wire arrives as $9,975 because an intermediary bank took a $25 fee
  • The customer sends $5,000 today and $5,000 next week
  • Two different customers send wires with the same reference number
  • A wire arrives with no reference number at all

Your matching engine needs rules for all of these cases. We use a tiered approach.

  1. Exact match. Reference number and amount both match a pending settlement. Confidence score 100. Auto settle.
  2. Reference match with amount tolerance. Reference matches but amount is within 2% of expected. Confidence score 80. Auto settle if within $50 variance, otherwise flag for manual review.
  3. Amount match with no reference. Amount exactly matches a single pending settlement and no other pending settlement has the same amount. Confidence score 60. Flag for review.
  4. Partial match. Amount is less than the expected settlement amount but the reference matches. Create a partial credit. The settlement stays in PARTIALLY_FUNDED until the remaining balance arrives or a timeout triggers a refund.
  5. No match. Park the funds in a suspense account. Alert operations. Do not touch the money until a human resolves it.

For partial settlements, the data model needs to support multiple fiat deposits mapping to a single settlement.

interface Settlement {
  id: string;
  requestedAmount: Decimal;
  fundedAmount: Decimal;  // sum of all matched deposits
  status: 'CREATED' | 'PARTIALLY_FUNDED' | 'FULLY_FUNDED'
        | 'CRYPTO_PENDING' | 'SETTLED' | 'REFUNDED';
  deposits: FiatDeposit[];
}

interface FiatDeposit {
  id: string;
  settlementId: string | null;  // null if unmatched
  amount: Decimal;
  reference: string | null;
  receivedAt: Date;
  matchConfidence: number;
}

Only trigger the crypto leg when fundedAmount >= requestedAmount. If there's an overpayment, record it and initiate a separate refund settlement for the difference. Never silently absorb the extra.

The reconciliation job

Run reconciliation continuously, not once a day. We run it every 15 minutes. The job does three things.

Balance check. For each account in the ledger, sum all debits and credits. The net balance should match the expected balance. On the fiat side, compare against your bank's reported balance via API. On the crypto side, compare against the onchain token balance of your hot wallet.

Settlement completeness check. Find all settlements older than 1 hour that are still in CRYPTO_PENDING. Query the blockchain for the transaction status. If the transaction was confirmed, update the settlement. If it was dropped, re-queue it. If the nonce was consumed but the transaction reverted, alert immediately because something went wrong with the transfer and you need to investigate.

Orphan detection. Find all fiat deposits older than 24 hours that haven't been matched to a settlement. Find all onchain transfers to your wallet that don't correspond to any known operation. These are your orphans. Each one needs investigation.

async function reconcile() {
  const mismatches: Mismatch[] = [];

  // 1. Balance check
  for (const account of accounts) {
    const ledgerBalance = await sumLedgerEntries(account.id);
    const externalBalance = await getExternalBalance(account);
    const diff = ledgerBalance.minus(externalBalance);

    if (diff.abs().greaterThan(TOLERANCE)) {
      mismatches.push({
        type: 'BALANCE_MISMATCH',
        account: account.id,
        ledgerBalance,
        externalBalance,
        difference: diff
      });
    }
  }

  // 2. Stuck settlement check
  const stuckSettlements = await db.settlements.findMany({
    where: {
      status: 'CRYPTO_PENDING',
      updatedAt: { lt: oneHourAgo() }
    }
  });

  for (const s of stuckSettlements) {
    await checkAndResolveStuck(s);
  }

  // 3. Orphan detection
  const unmatchedDeposits = await findUnmatchedDeposits(24);
  const unknownOnchainTransfers = await findUnknownTransfers(24);

  if (mismatches.length > 0 || unmatchedDeposits.length > 0) {
    await alertOps({ mismatches, unmatchedDeposits, unknownOnchainTransfers });
  }
}

The tolerance threshold deserves thought. Setting it to zero means you'll get alerts for rounding differences in fee calculations. We use $0.01 for fiat accounts and 100 wei equivalent for crypto accounts. That filters the noise without hiding real problems.

Handling blockchain failures

Blockchain transactions fail in ways that fiat transactions don't. Here are the ones that bite people.

Nonce gaps. If you're sending multiple stablecoin transfers from the same hot wallet, you need sequential nonce management. If transaction with nonce 42 gets stuck, every transaction after it queues behind it. Use a nonce manager that tracks pending nonces locally and can bump gas prices on stuck transactions.

Reorgs. A transaction you thought was confirmed gets rolled back in a chain reorganization. This is rare on Ethereum post merge but it happens on other chains. Wait for sufficient confirmations before marking a settlement as final. We use 12 on Ethereum, 35 on Polygon, and finalized commitment on Solana.

Token transfer reverts. The transfer call succeeds at the EVM level but returns false instead of reverting. USDC doesn't do this (it reverts on failure), but some other tokens use the return value pattern. Always check the return value or use OpenZeppelin's SafeERC20.

Gas price spikes. During network congestion, your transfer might sit in the mempool for hours. Set a maximum acceptable delay (we use 30 minutes) and auto bump gas by 20% if the transaction hasn't been included.

The fee accounting trap

Fees come from everywhere. Wire fees from intermediary banks. Gas fees for onchain transfers. Spread if you're converting between stablecoins. Exchange rate differences if you're settling cross currency.

Account for every fee as a separate ledger entry. Do not net fees out of the settlement amount. If a customer sends $10,000 and you charge a 0.1% fee, your ledger should show something like this.

  • CREDIT customer fiat account $10,000
  • DEBIT omnibus fiat account $10,000
  • DEBIT customer fee account $10
  • CREDIT revenue account $10
  • DEBIT omnibus crypto USDC 9,990
  • CREDIT customer crypto USDC 9,990

Gas fees for the onchain transfer come out of your operating account, not the customer's settlement. This keeps the customer's settlement amount clean and makes reconciliation straightforward.

What to monitor

In production, these are the metrics that matter.

  • Settlement completion time p50 and p99. Our target is under 10 minutes p50 and under 2 hours p99. The long tail is almost always the fiat leg.
  • Unmatched deposit count. Should trend toward zero. If it's growing, your matching rules need tuning.
  • Reconciliation delta. The absolute difference between your ledger balances and external balances, tracked over time. Spikes mean something is broken.
  • Hot wallet balance. Alert if it drops below your daily average settlement volume. You don't want to run out of stablecoins mid-day.
  • Stuck settlement count. Anything in CRYPTO_PENDING for over an hour needs attention.

We run these as Prometheus metrics with Grafana dashboards and PagerDuty alerts. The unmatched deposit alert fires after 4 hours, not 24, because early detection of matching failures saves a lot of manual work.

Start simple, iterate

If you're building this from scratch, start with exact match only and manual review for everything else. Get the double entry ledger right from day one because migrating to it later is painful. Add the partial settlement logic when you actually see partial payments in production. Add the automated gas bumping when you've had your first stuck transaction.

The reconciliation job is non negotiable from the start. Without it, you're flying blind. Every system I've seen that skipped reconciliation "because we'll add it later" ended up with unexplained balance differences that took weeks to untangle.

Build the boring accounting infrastructure first. The exciting crypto parts are the easy part.