Skip to main content
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. Use Node.js 22.13 or later, viem 2, and the application’s existing TypeScript setup. The built-in SQLite API 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.
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.

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.
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.
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 and sign using 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. 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().
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 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. 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:
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.

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. Verify whether that transfer reached the recipient or quarantine before updating the order. See MIP20 Reference and Transactions for event and transaction details.