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.
| Source | Authoritative for | How it is obtained |
|---|---|---|
| Chain | What actually moved. | Balances and transactions read from the gateway at a pinned block height. |
| Engine | What the system believes it did. | Position snapshots from the treasury and payout contexts. |
| Operator | What you owe your customers. | You report it — see below. Without this the third comparison cannot happen. |
Findings
| Finding class | Means | Severity |
|---|---|---|
CHAIN_ENGINE_MISMATCH | On-chain total disagrees with the engine's position. | CRITICAL |
ENGINE_OPERATOR_MISMATCH | The engine and your reported position disagree. | HIGH |
UNKNOWN_TRANSFER | Money arrived at an address nobody registered. | MEDIUM |
STUCK_PAYOUT | Broadcast but not settling past its window. | HIGH |
UNEXPLAINED_OUTFLOW | Value 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
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.
// 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() });
::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.