> ## Documentation Index
> Fetch the complete documentation index at: https://docs.metalntwx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify and reconcile payments

> Match finalized transfers to orders and recover interrupted processing without counting a payment twice

> [!NOTE]
>
> **What you'll be able to do**
>
> * Save each payment and its order update together.
> * Prevent the same payment from being counted twice.
> * Resume processing after a restart and catch up on payments missed during an outage.

Payment processing connects onchain transfers to orders in your application.
This guide matches incoming MIP20 transfers to saved payment instructions,
verifies finality, and records each payment with its order update in one database
transaction. It uses SQLite to preserve that progress across restarts.

## Prepare the processor

Use the MIP20 token address your application accepts and a recipient address it
controls. Verify the token address and convert the payment amount to base units
using [Direct payment](https://docs.metalntwx.com/build/payments/direct-payment.md).

Use Node.js 22.13 or later, viem 2, and the application's existing TypeScript
setup. The built-in [SQLite API](https://nodejs.org/api/sqlite.html) supplies
`DatabaseSync`. Keep its database file on persistent local storage. If the
application runs on several hosts, use a shared transactional database with the
same uniqueness and locking guarantees.

## Save an immutable payment instruction

Before accepting a payment, assign an order ID and save its expected chain ID,
token address, sender, recipient, amount in base units, and earliest acceptable
block. Normalize addresses to lowercase and store amounts and block numbers as
decimal strings to preserve their precision.

Make each unpaid order distinguishable by its recipient address or combination
of sender, token and amount. The matcher below uses these fields because the
MIP20 `Transfer` event contains only `from`, `to` and `value`. If a transfer
matches several unpaid orders, pause processing and resolve the ambiguity.

Create the tables below to store orders, payment evidence and processing
progress. The `application_effects` table records which event fulfilled each
order, with uniqueness constraints on both the order and event.

```typescript theme={null}
import { DatabaseSync } from "node:sqlite"

const db = new DatabaseSync("payments.sqlite")
db.exec("PRAGMA foreign_keys = ON")
db.exec("PRAGMA journal_mode = WAL")
db.exec("PRAGMA synchronous = FULL")
db.exec("PRAGMA busy_timeout = 5000")

const schema = [
  `CREATE TABLE IF NOT EXISTS orders (
    id TEXT PRIMARY KEY,
    status TEXT NOT NULL CHECK (status IN ('unpaid', 'paid'))
  )`,
  `CREATE TABLE IF NOT EXISTS instructions (
    order_id TEXT PRIMARY KEY REFERENCES orders(id),
    chain_id INTEGER NOT NULL,
    token TEXT NOT NULL,
    sender TEXT NOT NULL,
    recipient TEXT NOT NULL,
    amount TEXT NOT NULL,
    start_block TEXT NOT NULL
  )`,
  `CREATE TABLE IF NOT EXISTS verified_events (
    event_id TEXT PRIMARY KEY,
    order_id TEXT NOT NULL UNIQUE REFERENCES instructions(order_id),
    block_number TEXT NOT NULL,
    block_hash TEXT NOT NULL,
    tx_hash TEXT NOT NULL,
    log_index INTEGER NOT NULL,
    evidence_json TEXT NOT NULL
  )`,
  `CREATE TABLE IF NOT EXISTS application_effects (
    order_id TEXT PRIMARY KEY REFERENCES orders(id),
    event_id TEXT NOT NULL UNIQUE REFERENCES verified_events(event_id)
  )`,
  `CREATE TABLE IF NOT EXISTS quarantine_records (
    chain_id INTEGER NOT NULL,
    policy TEXT NOT NULL,
    receipt_id TEXT NOT NULL,
    order_id TEXT NOT NULL REFERENCES instructions(order_id),
    evidence_json TEXT NOT NULL,
    state TEXT NOT NULL CHECK (state IN ('quarantined', 'recovered')),
    PRIMARY KEY (chain_id, policy, receipt_id)
  )`,
  `CREATE TABLE IF NOT EXISTS cursors (
    stream TEXT PRIMARY KEY,
    block_number TEXT NOT NULL,
    block_hash TEXT NOT NULL
  )`,
  `CREATE TABLE IF NOT EXISTS outgoing_attempts (
    operation_id TEXT PRIMARY KEY,
    chain_id INTEGER NOT NULL,
    signer TEXT NOT NULL,
    intent_json TEXT NOT NULL,
    nonce TEXT,
    tx_hash TEXT UNIQUE,
    raw TEXT,
    state TEXT NOT NULL CHECK (state IN ('preparing', 'signed', 'submitted', 'final', 'reverted')),
    UNIQUE (chain_id, signer, nonce)
  )`,
  `CREATE UNIQUE INDEX IF NOT EXISTS signer_busy
    ON outgoing_attempts(chain_id, signer)
    WHERE state IN ('preparing', 'signed', 'submitted')`
]
for (const statement of schema) db.exec(statement)
```

Insert the order and instruction together in a `BEGIN IMMEDIATE` transaction.
Check that no unpaid instruction already has the same chain, token, sender,
recipient, and amount. Reject zero or invalid amounts before inserting. Set
`start_block` to the finalized head plus one when issuing a new instruction.
Treat instruction fields as immutable after issuance. Corrections require a new
instruction and a decision about how to handle payments against the old one.

Track each chain and token pair as a stream, identified by a key such as
`63825:<lowercase-token-address>`. Its cursor stores the last processed block's
number and hash. Initialize a new stream's cursor to `start_block - 1` and the
canonical block hash at that height, then advance it as each block is processed.
The cursor comparison below coordinates workers processing the same stream.

## Verify a finalized transfer

`verifyFinalizedTransfer` checks a transfer log against the saved instruction in
`expected`. Supply its transaction hash and the block-wide `logIndex` returned by
RPC. Use the same endpoint for `client` and `rpcUrl` so receipt, block and finality
checks use the same node.

```typescript theme={null}
import { decodeEventLog, parseAbiItem, type Address, type Hash, type PublicClient } from "viem"

const transferEvent = parseAbiItem(
  "event Transfer(address indexed from, address indexed to, uint256 value)"
)

type Instruction = {
  chainId: number
  token: Address
  sender: Address
  recipient: Address
  amount: bigint
  startBlock: bigint
}

export async function readFinalizedHeight(rpcUrl: string) {
  const response = await fetch(rpcUrl, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "consensus_status", params: [] }),
    signal: AbortSignal.timeout(10_000)
  })
  const body = await response.json()
  const number = body.result?.finalized?.number
  if (!response.ok || body.error || number === undefined) {
    throw new Error("Finalized progress is unavailable")
  }
  if (typeof number === "number" && !Number.isSafeInteger(number)) {
    throw new Error("Unsafe finalized height")
  }
  const height = BigInt(number)
  if (height < 0n) throw new Error("Invalid finalized height")
  return height
}

export async function verifyFinalizedTransfer(
  client: PublicClient,
  rpcUrl: string,
  expected: Instruction,
  hash: Hash,
  logIndex: number
) {
  if (expected.amount <= 0n) throw new Error("Expected amount must be positive")
  if (await client.getChainId() !== expected.chainId) throw new Error("Wrong network")
  const receipt = await client.getTransactionReceipt({ hash })
  if (receipt.transactionHash !== hash || receipt.status !== "success") {
    throw new Error("Transaction hash mismatch or failed execution")
  }
  if (receipt.blockNumber < expected.startBlock) throw new Error("Transfer predates instruction")
  if (await readFinalizedHeight(rpcUrl) < receipt.blockNumber) {
    throw new Error("Transfer is awaiting finality")
  }
  const block = await client.getBlock({ blockNumber: receipt.blockNumber })
  if (block.hash !== receipt.blockHash) throw new Error("Noncanonical receipt")
  const log = receipt.logs.find((entry) => entry.logIndex === logIndex)
  if (!log || log.removed || log.address.toLowerCase() !== expected.token.toLowerCase() ||
      log.transactionHash !== hash || log.blockHash !== receipt.blockHash) {
    throw new Error("Unexpected event identity")
  }
  const { args } = decodeEventLog({
    abi: [transferEvent], data: log.data, topics: log.topics, strict: true
  })
  if (args.from.toLowerCase() !== expected.sender.toLowerCase() ||
      args.to.toLowerCase() !== expected.recipient.toLowerCase() ||
      args.value !== expected.amount) {
    throw new Error("Transfer does not match instruction")
  }
  return {
    eventId: `${expected.chainId}:${hash}:${logIndex}`,
    blockNumber: receipt.blockNumber.toString(),
    blockHash: receipt.blockHash,
    txHash: hash,
    logIndex,
    chainId: expected.chainId,
    token: expected.token.toLowerCase(),
    sender: expected.sender.toLowerCase(),
    recipient: expected.recipient.toLowerCase(),
    amount: expected.amount.toString()
  }
}
```

## Discover and commit one finalized block

Read `consensus_status`, then process blocks from the saved cursor plus one
through the reported finalized height. Fetch `Transfer` logs for the token with
`getLogs({ address: token, event: transferEvent, fromBlock, toBlock })`. Use
bounded ranges that the endpoint supports, and process every block in order,
including blocks with no relevant events.

Each event's `eventId` combines the chain ID, transaction hash and log index.
Skip events already recorded under that ID. For each new log, find unpaid
instructions with the same chain, token, sender, recipient and amount whose
`start_block` is at or before the log's block. Verify a single match with
`verifyFinalizedTransfer`, retain unmatched transfers for reconciliation, and
pause the stream if several instructions match.

Process logs in transaction order, then log order. Once an event matches an
order, exclude that order from further matches in the same block. Retain any
additional payment against it for reconciliation. If the payment instructions
leave it unclear which event fulfills the order, pause the stream until you
resolve the match.

Prepare all verified matches for a block before opening the database
transaction. Each entry in `matches` pairs an `orderId` with the `evidence`
returned by `verifyFinalizedTransfer`. The transaction below saves that evidence,
marks the orders paid, and advances the cursor together.

```typescript theme={null}
type Evidence = Awaited<ReturnType<typeof verifyFinalizedTransfer>>

function commitBlock(
  stream: string,
  previous: { number: string, hash: string },
  block: { number: bigint, hash: string, parentHash: string },
  matches: { orderId: string, evidence: Evidence }[]
) {
  db.exec("BEGIN IMMEDIATE")
  try {
    const cursor = db.prepare("SELECT block_number, block_hash FROM cursors WHERE stream = ?")
      .get(stream) as { block_number: string, block_hash: string } | undefined
    if (!cursor || cursor.block_number !== previous.number || cursor.block_hash !== previous.hash ||
        block.number !== BigInt(previous.number) + 1n || block.parentHash !== previous.hash) {
      throw new Error("Cursor changed or block continuity failed")
    }
    for (const { orderId, evidence } of matches) {
      if (evidence.blockNumber !== block.number.toString() || evidence.blockHash !== block.hash) {
        throw new Error("Evidence belongs to another block")
      }
      const old = db.prepare("SELECT order_id FROM verified_events WHERE event_id = ?")
        .get(evidence.eventId) as { order_id: string } | undefined
      if (old) {
        if (old.order_id !== orderId) throw new Error("Event already assigned")
        continue
      }
      const instruction = db.prepare("SELECT * FROM instructions WHERE order_id = ?")
        .get(orderId) as Record<string, string | number> | undefined
      if (!instruction || instruction.chain_id !== evidence.chainId ||
          instruction.token !== evidence.token || instruction.sender !== evidence.sender ||
          instruction.recipient !== evidence.recipient || instruction.amount !== evidence.amount ||
          BigInt(instruction.start_block) > block.number) {
        throw new Error("Instruction changed or evidence mismatched")
      }
      db.prepare("INSERT INTO verified_events VALUES (?, ?, ?, ?, ?, ?, ?)").run(
        evidence.eventId, orderId, evidence.blockNumber, evidence.blockHash,
        evidence.txHash, evidence.logIndex, JSON.stringify(evidence)
      )
      db.prepare("INSERT INTO application_effects VALUES (?, ?)").run(orderId, evidence.eventId)
      const changed = db.prepare("UPDATE orders SET status = 'paid' WHERE id = ? AND status = 'unpaid'")
        .run(orderId).changes
      if (changed !== 1) throw new Error("Order was already completed")
    }
    db.prepare("UPDATE cursors SET block_number = ?, block_hash = ? WHERE stream = ?")
      .run(block.number.toString(), block.hash, stream)
    db.exec("COMMIT")
  } catch (error) {
    db.exec("ROLLBACK")
    throw error
  }
}
```

Pass `commitBlock` the canonical block fetched through the same endpoint, after
checking that its number is at or below the finalized height. If two workers
read the same cursor, the first commits the block and the other reloads the
cursor before continuing. The uniqueness constraints prevent either worker from
counting a payment twice.

Save each instruction before processing its acceptable block range. When importing
an older instruction, replay from its start block while preserving the event
uniqueness checks.

If fulfillment calls another service, save a pending delivery record in the same
database transaction. Send the request after committing, using `eventId` as the
receiving service's idempotency key so it can recognize retries. This lets you
retry delivery after a crash without fulfilling the order twice.

## Save outgoing attempts before submission

Assign each outgoing payment an operation ID and save its intended contract
call. Insert a `preparing` attempt to reserve the signing account while the
payment is submitted and checked. The `signer_busy` index permits one unresolved
attempt per chain ID and signing account, so all processes using that account
must share this database.

```typescript theme={null}
db.prepare(`INSERT INTO outgoing_attempts
  (operation_id, chain_id, signer, intent_json, state)
  VALUES (?, ?, ?, ?, 'preparing')`)
  .run(operationId, chainId, signer.toLowerCase(), JSON.stringify(intent))
```

Here `operationId` is the saved request ID, `signer` is the signing account's
address, and `intent` contains the contract address, ABI version, function,
arguments, gas token, and payment instruction ID. Encode integer fields as
decimal strings. Keep this intended call fixed once the attempt is created. If
the insert conflicts with an existing operation ID, return that operation's
record. If the signing account is busy, wait until its current attempt is
resolved before inserting another.

While retaining exclusive use of the signing account, estimate the fee through
[Estimate and record fees](https://docs.metalntwx.com/build/prepare/estimate-and-record-fees.md) and sign using
[`signMip20Contract`](https://docs.metalntwx.com/reference/typescript-sdk#signmip20contract). The helper
fetches the pending nonce itself, so dedicate the account to this processor. If
another system must also submit from the account, coordinate nonce allocation
and pass an explicit nonce through the
[codec API](https://docs.metalntwx.com/reference/typescript-sdk.md#codec-api).

For `signMip20Contract`, record the pending nonce immediately before signing and
read it again afterward while retaining exclusive use of the signing account.
If it changed, stop and reconcile the account before signing again. Otherwise,
save the nonce, `signed.txHash` and `signed.raw` before calling `signed.send()`.

```typescript theme={null}
const saved = db.prepare(`UPDATE outgoing_attempts
  SET nonce = ?, tx_hash = ?, raw = ?, state = 'signed'
  WHERE operation_id = ? AND state = 'preparing'`)
  .run(nonce.toString(), signed.txHash, signed.raw, operationId)
if (saved.changes !== 1) throw new Error("Attempt ownership lost")

const returnedHash = await signed.send()
if (returnedHash !== signed.txHash) throw new Error("Unexpected transaction hash")
db.prepare("UPDATE outgoing_attempts SET state = 'submitted' WHERE operation_id = ? AND state = 'signed'")
  .run(operationId)
```

A worker processing transaction receipts may resolve the attempt before the
broadcast response arrives. The conditional update preserves its `final` or
`reverted` state. If no row changes, reload the attempt and return its current
result.

Restrict access to the database and its backups because anyone with the signed
bytes can broadcast the transaction. Keep private keys in the application's
separate signing setup.

## Recover an uncertain submission

Read the saved attempt and query the transaction and receipt by its hash. For
an included transaction, check the execution result, canonical block and
finality, then verify the events required by the payment procedure. Once the
transaction is finalized, record successful execution as `final` or a revert as
`reverted`, together with the transaction fee. Mark the order paid only after
the payment procedure's completion checks pass.

A successful finalized transaction can send funds to ReceivePolicy quarantine.
Verify the complete [quarantine receipt](https://docs.metalntwx.com/reference/compliance/receive-policy.md) against the
saved instruction and actual token transfer. Require the documented policy
contract as the event emitter and check the receipt ID, token, originator,
intended recipient (`receiver`), recovery authority and amount. Save the full
quarantine receipt and its transaction, block and log identifiers in
`quarantine_records.evidence_json`. In the same database transaction, mark the
outgoing attempt `final` and record any consumed authorization, hold allocation
or note in the payment procedure's records.

Keep the order `unpaid` while the funds are in quarantine. The `final` attempt
state records completed transaction processing, while `verified_events` and
`application_effects` record fulfillment of the order.

Resume quarantine recovery using the saved quarantine receipt ID and the
[claim verification checks](https://docs.metalntwx.com/reference/compliance/receive-policy.md#read-and-recover-a-quarantined-credit).
The claim transfers funds from ReceivePolicy, so verify it against the saved
quarantine receipt. Mark the record `recovered` after verifying the claim, and
fulfill the original order only if the funds reached its intended recipient.
Keep recovery linked to the order so it can be fulfilled once, and wait for
recovery to finish before deciding whether a replacement payment is needed.

If no transaction receipt is available, query the saved hash and the signing
account's pending and latest nonces to determine whether the transaction is
pending or its nonce has been used. Keep the outcome unresolved if the node
cannot find the transaction. When the saved transaction remains valid,
rebroadcast its exact bytes:

```typescript theme={null}
const hash = await client.sendRawTransaction({ serializedTransaction: savedRaw })
if (hash !== savedHash) throw new Error("Unexpected transaction hash")
```

A response such as `already known` or `nonce too low` still requires reconciliation.
If the nonce was consumed by another transaction, identify that transaction and
its outcome before allowing another payment. Keep new submissions from the
signing account blocked while the outcome is unresolved.

To resume a `preparing` attempt with no saved signed bytes, first ensure that its
previous worker has stopped and can no longer submit. Then check the signing
account's transaction history and nonce before signing again. A timeout alone
is insufficient to release the account because the previous worker may still
broadcast.

## Test restart and replay

Create one incoming instruction and send a small payment for its exact amount.
Keep the transaction hash and log index for repeated verification.

| Test | Required result |
| - | - |
| Deliver the same event twice | One verified event, one application effect, and one paid order |
| Receive two matching transfers in one block | Credit the chosen event once and retain the additional payment for reconciliation |
| Throw after event insertion but before order update | Rollback leaves all tables unchanged |
| Stop after a block commit, then restart | Resume at the next block without another credit |
| Stop discovery while blocks advance | Catch up from the saved cursor, including empty blocks |
| Present a transfer with the wrong token, sender, recipient or amount, or one before `start_block` | No order update |
| Present a transaction receipt from an unfinalized or noncanonical block | No order update and no cursor advance past that block |
| Start two outgoing workers for one signing account | Only one obtains exclusive use of that account |
| Discard the broadcast response | Resolve or rebroadcast the saved hash and bytes without signing a second transaction |
| Receive a broadcast acknowledgement after finality | Preserve the final or reverted attempt state |
| Finalize a payment into quarantine | Persist its quarantine receipt and consumed authorization, hold allocation or note state, leave the order unpaid, and do not create a replacement payment |
| Restart while quarantine recovery is pending | Recover by quarantine receipt ID without restoring a consumed authorization, hold allocation or spent note |

## Apply completion rules for other flows

Apply the same event deduplication, block and finality checks, and database
transactions to other payment flows. Each procedure defines when to mark the
payment complete, such as a hold capture, shielded payment resolution, Clearing
settlement or bridge destination credit. Follow that procedure's completion
checks before updating the order.

For ERC-3009, match the token's `AuthorizationUsed(authorizer, nonce)` to its
associated transfer using
[collection verification](https://docs.metalntwx.com/build/payments/authorized-payment.md#verify-and-record-collection).
Verify whether that transfer reached the recipient or quarantine before
updating the order. See [MIP20 Reference](https://docs.metalntwx.com/reference/assets-and-payments/mip20.md) and
[Transactions](https://docs.metalntwx.com/reference/network-fundamentals/transactions.md) for event and transaction details.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.