Decode TIP-20 transfer memos with Turbo and match onchain settlements to your ledger in real time.
Traditional payment reconciliation is a batch, T+1/T+2 process: at the end of the day, an operations team matches a settlement file against the ledger and chases the breaks by hand. It is slow, expensive, and it hides problems until the next morning.Stablecoin settlement on a payments chain removes the delay from the movement of money. This guide removes the delay from the accounting for it. You will decode TIP-20 transfer memos with a Turbo pipeline, stream them into your own ledger database, and match each settlement to an invoice on its memo - continuously, in seconds.
A plain ERC-20 Transfer tells you who paid whom, how much. It does not tell you what for. That single missing field is why onchain payments have historically needed a separate, off-chain mapping to be reconcilable at all.TIP-20 adds it at the protocol level. Every payment can carry a 32-byte memo:
event TransferWithMemo( address indexed from, address indexed to, uint256 amount, bytes32 indexed memo // your invoice ID, order number, or PSP reference);
Put your reference in the memo when you initiate the payment, and reconciliation stops being a fuzzy amount-and-timestamp match. It becomes a deterministic join on a key you control.
A Postgres database and a Goldsky secret with its connection string.
The contract address of the TIP-20 token you settle in, and its ABI. Confirm the exact Tempo dataset name in the Datasource explorer or with goldsky dataset list.
TIP-20 events are ordinary Solidity logs, so they land in the raw_logs dataset and can be decoded inside the pipeline. This mirrors the decode custom contract events pattern.
reconciliation-pipeline.yaml
name: tip20-reconciliationresource_size: ssources: tempo_tip20_logs: type: dataset dataset_name: tempo.raw_logs version: 1.0.0 start_at: latest # Fast-scan: only ingest logs from your settlement token filter: address = lower('0xYOUR_TIP20_TOKEN')transforms: # 1. Decode the raw log with the TIP-20 ABI decoded_payments: type: sql primary_key: id sql: | SELECT id, address AS token, _gs_log_decode( _gs_fetch_abi('https://raw.githubusercontent.com/your-org/abis/main/tip20.json', 'raw'), topics, data ) AS decoded, block_number, block_timestamp, transaction_hash, _gs_op FROM tempo_tip20_logs WHERE address = lower('0xYOUR_TIP20_TOKEN') # 2. Keep only TransferWithMemo events and flatten the fields settlements: type: sql primary_key: id sql: | SELECT id, token, decoded.event_params[1] AS from_address, decoded.event_params[2] AS to_address, CAST(decoded.event_params[3] AS DECIMAL(38, 0)) AS amount, decoded.event_params[4] AS memo, to_timestamp(block_timestamp) AS settled_at, transaction_hash, _gs_op FROM decoded_payments WHERE decoded IS NOT NULL AND decoded.event_signature = 'TransferWithMemo'sinks: ledger_settlements: type: postgres from: settlements schema: ledger table: onchain_settlements secret_name: MY_POSTGRES primary_key: id
Don’t want to hardcode the ABI? _gs_fetch_abi can pull it from a block explorer with an Etherscan-compatible API (_gs_fetch_abi('<explorer-url>', 'etherscan')). Fetching keeps the pipeline in sync if the token’s interface is upgraded.
Your onchain_settlements table now fills in real time, next to your existing invoices table. Because you control the memo, matching is a single join. Encode your invoice reference into the 32-byte memo when you create the payment (for example a zero-padded ID or a hash), and store the same encoding on the invoice.
SELECT i.invoice_id, i.amount_due, s.amount AS amount_settled, s.settled_at, s.transaction_hash, CASE WHEN s.id IS NULL THEN 'unpaid' WHEN s.amount >= i.amount_due THEN 'reconciled' WHEN s.amount < i.amount_due THEN 'short_paid' END AS statusFROM ledger.invoices iLEFT JOIN ledger.onchain_settlements s ON lower(s.memo) = lower(i.memo_ref)WHERE i.status = 'open';
Everything that returns unpaid past its due date, short_paid, or arrives with a memo that matches no invoice (an unexpected payment) is a reconciliation break - surfaced the moment it settles, not the next morning.
Two options turn the query above into an always-on control.
Live exception totals (Postgres aggregate)
Instant break alerts (webhook)
Use the PostgreSQL aggregate sink to maintain a running, real-time rollup - for example a running total settled per token - without a scheduled job.
sinks: settled_totals: type: postgres_aggregate from: settlements schema: ledger landing_table: settlements_log agg_table: settled_totals primary_key: id secret_name: MY_POSTGRES group_by: token: type: text aggregate: total_settled: # running sum, maintained by a DB trigger from: amount fn: sum type: numeric(38,0) settlement_count: fn: count
Send unmatched settlements straight to your ops tooling. Add a transform that flags settlements whose memo is not in your open-invoice set (a dynamic table of expected references), then route it to a webhook sink:
transforms: # Expected payment references, kept in sync from your invoicing system open_invoices: type: dynamic_table backend_type: Postgres backend_entity_name: open_invoices secret_name: MY_POSTGRES # A settlement whose memo matches no open invoice is a break unmatched_settlements: type: sql primary_key: id sql: | SELECT id, token, from_address, amount, memo, settled_at, transaction_hash, _gs_op FROM settlements WHERE NOT dynamic_table_check('open_invoices', lower(memo))sinks: # Only unmatched settlements reach the break URL - reconciled payments do not break_alerts: type: webhook from: unmatched_settlements url: https://ops.example.com/reconciliation/break one_row_per_request: true secret_name: OPS_WEBHOOK_AUTH
Keep open_invoices current from your invoicing system - insert a reference when an invoice opens, delete it when it is paid or cancelled - so the break feed carries only genuinely unexpected settlements.
Reconciliation is only as reliable as the data feeding it. Point the indexers and verification jobs behind this workflow at Edge RPC:
Cross-validation and block-range enforcement mean you never reconcile against a missing log or a gap in eth_getLogs.
Automatic failover keeps the ledger current through provider outages.
On Tempo specifically, transactions reach sub-second, re-org-free finality, so a settlement you record will not later be unwound - the property tradfi back-offices have always wanted from a settlement rail.
For breaks that can be handled programmatically - retrying a failed payout, issuing a refund for an overpayment, or opening a ticket - trigger a Compose task from the exception. Compose gives you durable execution (the action completes even through failures) and a full trace of every step, so each automated resolution is auditable. See task triggers to fire a task from a webhook or onchain event.