DEV Community

Robin Doelman
Robin Doelman

Posted on

Crediting crypto deposits safely: confirmation thresholds per network (with code)

Any app that accepts on-chain deposits — exchanges, wallets, games — has to answer one question: when is a deposit real enough to credit? Credit too early and a reorg or double-spend costs you money. Credit too late and users file support tickets.

I worked through this for casino wallets, where the other half of the problem is operational: support and payments staff need to see each deposit's state without becoming blockchain specialists (that side is covered in Spinlab Studio's admin panel guide). This post is the engineering side, and it applies to any custodial wallet.

Model deposits as a state machine

SEEN (in mempool / first block) → CONFIRMING (n of N) → CREDITED → (rare) REVERSED
Enter fullscreen mode Exit fullscreen mode

Store each deposit with tx_hash, network, asset, amount, block_number, confirmations, status. Never credit from the mempool state.

Thresholds are per network, not per asset

USDT on Ethereum and USDT on TRON are different risks. Make the threshold a config keyed by (network, asset) and optionally by amount band:

type Rule = { network: string; asset: string; maxAmountUsd: number; confirmations: number };

const rules: Rule[] = [
  { network: "bitcoin",  asset: "BTC",  maxAmountUsd: 1_000,    confirmations: 1 },
  { network: "bitcoin",  asset: "BTC",  maxAmountUsd: Infinity, confirmations: 3 },
  { network: "ethereum", asset: "*",    maxAmountUsd: Infinity, confirmations: 12 }, // or wait for finalized
  { network: "tron",     asset: "USDT", maxAmountUsd: Infinity, confirmations: 19 }, // ~solidified
  { network: "solana",   asset: "*",    maxAmountUsd: Infinity, confirmations: 0 },  // use 'finalized' commitment instead
];

export function required(network: string, asset: string, usd: number): number {
  const r = rules.find(r => r.network === network && (r.asset === asset || r.asset === "*") && usd <= r.maxAmountUsd);
  if (!r) throw new Error(`no confirmation rule for ${network}/${asset}`); // fail closed
  return r.confirmations;
}
Enter fullscreen mode Exit fullscreen mode

The numbers above are common starting points, not gospel — tune them to your risk appetite, and prefer the chain's own finality signal (Ethereum finalized block tag, Solana finalized commitment) where one exists.

Three bugs I see constantly

  1. Crediting twice. Make the credit operation idempotent on (network, tx_hash, output_index). Webhooks from node providers will be delivered more than once.
  2. Ignoring reorgs. After crediting, keep watching until finality. If the block disappears, move the deposit to REVERSED and lock the balance delta instead of silently subtracting.
  3. Failing open on unknown networks. If a user sends a token on a network you don't support, don't guess. Park it in a manual-review queue.

Show the state to the user

A progress indicator ("2 of 3 confirmations") removes most support tickets. Expose the same state machine to your internal tools so an agent can answer "where's my deposit?" from one screen. Users tolerate waiting; they don't tolerate not knowing.


Questions about edge cases — RBF transactions, token contracts with transfer fees, chain halts — are welcome in the comments.

Top comments (0)