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.
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 suppliesDatabaseSync. 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 MIP20Transfer 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.
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
Readconsensus_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.
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 apreparing 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.
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().
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 asfinal 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:
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’sAuthorizationUsed(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.