tfpdev
3.7Part 3 · Reconciliation

Stuck Deposits

Three clients pay. The service that's meant to tell you about payments tells you about one on time, one late and one never. Trust it, and you chase a client who already paid. Check the payments yourself and you catch it.

01Try it

Three clients pay three bills, for real. The service that reports payments is simulated: it reports one on time, one late, and forgets one.

Three bills · 5,800 USDC each
Three real test payments, one per bill. The payment service is simulated: it tells you about one on time, one late, and one never.15 of 15 runs left this hour
Simulator
  1. Client Anothing yet
  2. Client Bnothing yet
  3. Client Cnothing yet
BreaksWaits to be told
Marks a bill paid only when the payment service says so.
  • Client A—
  • Client B—
  • Client C—
HoldsChecks for itself
Looks at the payments itself and compares with what the service says.
  • Client A—
  • Client B—
  • Client C—

Three bills for 5,800 USDC. All three clients pay at once. Then see what the payment service says, and what really happened.

15 tries an hour each. Every payment is real and uses up the lab's test money.

02Step 2

Why check for yourself

Messages go missing

The service tries to tell you, but messages get lost. Nothing breaks on your side. You just never find out.

The payment record stays

The payment itself is on a public record that anyone can check. Looking at it takes one quick step and needs nobody to remember to tell you.

A gap is a question

When the service and the record disagree, don't chase the client. Mark it paid from the record and ask the service what happened.

03Step 3

The code

What runs behind the buttons, in one sentence. The code itself is one click away.

The bad version only marks a bill paid when the service says so. The good version checks the payments itself every few seconds and asks the service about anything it missed.

Show the code anyway
BreaksBreaks
The version that loses money. Read the grey notes under it, not the code.
webhook-only.tsts
app.post("/webhooks/deposits", async (req) => {  const e = verify(req);  if (e.type === "deposit.confirmed") {    await invoices.markPaid(e.invoiceId, e.amount);  }});// and nothing else ever marks an invoice paid
The provider is the only source of truth. A dropped webhook isn't an error anywhere: the invoice just stays unpaid.
HoldsHolds
The version running behind the buttons above.
reconcile.tsts
const onChain = await getLogs(USDC.Transfer, { to: openInvoiceAddresses, fromBlock });for (const inv of openInvoices) {  const tx = onChain.find(l => l.to === inv.address);  if (!tx || confirmations(tx) < N) continue;  await invoices.markPaid(inv.id, tx.value, { source: "chain", tx: tx.hash });  if (!webhooks.has(tx.hash) && blocksSince(tx) > GRACE) {    await tickets.open("provider", { invoice: inv.id, tx: tx.hash });  }}
Webhooks are a fast path. The chain is the record. Every sync compares the two and turns any gap into a ticket.
04Step 4

Still curious?

A few more questions people ask. Open what you need.

What's real here?

The payments are real, and so is the careful side's check: it reads them from the public record itself. The payment service and its messages are simulated.

Why use a payment service at all?

Watching payments across many networks is real work, and these services usually do it well. Keep them. Just don't treat their message as the only proof.

What about the late one?

B shows the other problem: the careless side gets it right in the end, but first it chases a client who already paid. The careful side marked B paid straight away and only noted that the service was slow.

Not sure this could happen to you?

That's what the call is for. Tell me how your product works, and I'll tell you what applies to you and what I'd check first.