Payments Engine

Deposits

Register an address for a customer account, and the engine credits what arrives at it — exactly once, even if the chain is re-scanned, the process restarts, or the block that carried it is later orphaned.

Current status

Running end to end on a local chain. The worker reads blocks, confirms at the configured depth, withdraws credit on a reorganisation, sends signed webhooks, and sweeps confirmed deposits into your payout wallet. npm run e2e:local-chain exercises all of it. It has not been run on a public network yet, and only account-model (EVM) networks are supported.

Getting an address

One address per customer account, per network. The engine derives it from your operator seed, records the derivation index and starts watching it. You never supply an address: one the engine was only told about is one it might not be able to move money out of.

node
const issued = await payments.deposits.issueAddress({
  accountRef: 'account_4471',   // your account identifier
  chainId: 'eth_devnet'          // from GET /v1/assets
});
// → 201 { addressId, address, chainId, accountRef, assets: [...] }
// Ask again for the same account: 200, the same address.
FieldNotes
addressIdYour identifier for this registration. Reusing one is how you make registration idempotent.
chainIdMust be a chain the gateway is configured for.
addressThe on-chain address. Refused if it is already watched for a different account.
accountRefYour customer account. Appears on every credited transfer and is what GET /v1/deposits filters by.
derivationIndexDecimal string. Unique per chain — two accounts cannot share an index.
Two accounts can never share an address. A second registration of the same address under a different account is a 400, not an overwrite. Without that constraint, a deposit would be credited to whichever account happened to be looked up first — a bug that is invisible until the wrong customer withdraws.

Lifecycle of a deposit

OBSERVED
Seen in a block
Written under UNIQUE (chain, tx_hash, output_index). A re-scan of the same range inserts nothing.
CONFIRMING
Accruing depth
Confirmations counted against the chain policy. Your application should not credit a customer yet.
CONFIRMED
Threshold reached
A signed webhook fires. This is the event to credit a balance on.
REORGED
Block orphaned
A terminal, separate state — the record is never deleted, so your reconciliation can still see it.

The uniqueness constraint is the interesting one. Detection is idempotent at the database level rather than by remembering what has been processed, so a watcher that crashes and re-scans a range costs nothing but time.

Re-orgs

A confirmed deposit whose block is later orphaned becomes REORGED and a deposit.reorged event fires. The row is not deleted: your reconciliation needs to see that it existed, and a deleted row is indistinguishable from one that never happened.

Handle the reversal in your application. The engine tells you the deposit is gone; only you know whether the customer has already staked the credit. That decision — claw back, absorb, or flag for review — is business policy, and the engine deliberately does not make it for you.

Confirmation thresholds are configured per chain. Higher thresholds make a re-org less likely and deposits slower; that trade-off is yours.

Reading deposits

node
const deposits = await payments.deposits.list('account_4471');

deposits.transfers.forEach(t => {
  t.amountMinor;   // "250000000" — a string
  t.blockHeight;   // "19482013"  — also a string
  t.status;        // OBSERVED | CONFIRMING | CONFIRMED | REORGED
});

Reads are scoped to the caller in the query itself, not filtered afterwards. An endpoint that fetched every operator's rows and then discarded the others has already read them, which is a different security property.

An account with no registered address answers 404 rather than an empty list — the two mean different things, and confusing them hides an onboarding bug.

Transfers to addresses you never registered

Money does arrive at addresses nobody expected — a customer pastes an old address, a sweep goes to the wrong tier, someone donates. Those are recorded as unknown transfers rather than discarded, because a discarded transfer is money that exists on-chain and nowhere in your books, and reconciliation will find the gap without being able to explain it.