Payments Engine

Reconciliation

Three independent views of the same money — the chain, the engine, and your own books — compared against each other. A disagreement becomes a finding with a severity, and closing one requires a person, an actor and a reason.

Why three ways and not two

Comparing the engine against the chain catches detection and broadcast bugs. Comparing the engine against your books catches integration bugs. Neither catches the case where both agree and both are wrong — which is the one that costs the most, because nothing anywhere disagrees.

SourceAuthoritative forHow it is obtained
ChainWhat actually moved.Balances and transactions read from the gateway at a pinned block height.
EngineWhat the system believes it did.Position snapshots from the treasury and payout contexts.
OperatorWhat you owe your customers.You report it — see below. Without this the third comparison cannot happen.
The operator view is the one most deployments skip. Reporting your own position takes one call and is what turns two-way reconciliation into three-way. Without it, an integration bug that credits a customer twice is invisible: the chain and the engine agree perfectly, and only your books know.

Findings

Finding classMeansSeverity
CHAIN_ENGINE_MISMATCHOn-chain total disagrees with the engine's position.CRITICAL
ENGINE_OPERATOR_MISMATCHThe engine and your reported position disagree.HIGH
UNKNOWN_TRANSFERMoney arrived at an address nobody registered.MEDIUM
STUCK_PAYOUTBroadcast but not settling past its window.HIGH
UNEXPLAINED_OUTFLOWValue left an address without a matching payout.CRITICAL
UNEXPLAINED_OUTFLOW is the one to page on at any hour. Every other finding is a bookkeeping disagreement. This one describes money leaving custody with no instruction behind it, and the appropriate response is to halt signing first and investigate second.

Reading findings

node
const { openFindings } = await payments.reconciliation.openFindings();

// An empty list is a real answer: nothing disagrees.
for (const finding of openFindings) {
  finding.findingClass;       // CHAIN_ENGINE_MISMATCH
  finding.severity;           // CRITICAL
  finding.discrepancyMinor;   // "4200" — a string
  finding.assetCode;
  finding.createdAt;
}

The operator console renders this list continuously. A deployment with no alerting on it still has the finding recorded — but somebody has to be looking.

Reporting your own position

Report what your books say you owe, per asset, on a schedule. Daily is enough for most operators; hourly during a migration or after an incident.

node
// Sum your own account balances, in minor units, as a string.
const owed = await db.query(
  `SELECT SUM(balance_minor)::text AS total FROM customer_balances WHERE asset = $1`,
  ['USDT']
);

await payments.request('POST', '/v1/recon/operator-positions', {
  assetCode: 'USDT',
  positionMinor: owed.rows[0].total,   // ::text — never a JS number
  asOfTime: new Date().toISOString()
});
Note the ::text cast. SUM() over a NUMERIC column comes back through most drivers as a JavaScript number — so a reconciliation intended to catch rounding errors introduces one. Cast in SQL.

Closing a finding

A finding is closed by a person, and the resolution row requires an actor and a reason. Nothing auto-resolves, and nothing expires.

That constraint is the whole point. A system that quietly clears its own discrepancies is a system with no reconciliation at all — the row disappears and the question of who decided it was fine has no answer. The resolution log is what an auditor reads, and what you read yourself six months later when the same class of finding reappears.