import { Hash } from 'ox' import * as z from 'zod/mini' import * as Db from '../../db/Db.js' import * as FundingTransferTransactions from '../../db/tables/fundingTransferTransactions.js' import * as FundingTransfers from '../../db/tables/fundingTransfers.js' import type * as Metrics from '../../Metrics.js' import * as Schema from '../Schema.js' import type * as Webhooks from '../Webhooks.js' import * as Catalog from './Catalog.js' import type * as Provider from './Provider.js' import * as Stargate from './providers/stargate.js' import * as Transfer from './Transfer.js' const followUpDelaySeconds = 15 const maximumFollowUpAttempts = 120 const sweepLimit = 25 const providerState = z.object({ destinationBlockNumber: z.string().check(z.regex(/^\d+$/)), routeConfiguration: Stargate.schema.Configuration, stargate: z.object({ guid: Schema.Hash, sourceBlock: z.optional( z.object({ hash: Schema.Hash, number: z.string().check(z.regex(/^\d+$/)), }), ), sourceEid: z.number().check(z.int(), z.positive()), }), }) /** Queue message that advances one registered funding transfer. */ export type Message = { /** Zero-based destination reconciliation attempt. */ attempt?: number | undefined /** Funding transfer id (`ftr_…`). */ transferId: string /** Funding transfer reconciliation discriminator. */ type: 'funding:transfer:reconcile' } /** Creates a tracker that verifies and completes registered funding transfers. */ export function createTracker(options: createTracker.Options): createTracker.Tracker { const fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init)) let sweepCursor: string | undefined const record = (outcome: string, count = 1) => { options.metrics?.count('funding_transfer_reconciliation_count', count, { outcome, provider: 'stargate', }) } return { async reconcile(message) { const db = Db.get(options.db) const transfer = await FundingTransfers.get(db, message.transferId) if (!transfer || transfer.status === 'completed') { record(transfer ? 'already_completed' : 'not_found') return { type: 'ignored' } } if (transfer.status !== 'processing' || transfer.providerId !== 'stargate') { record('invalid_state') return { type: 'ignored' } } const state = providerState.safeParse(transfer.providerState) const transactions = await FundingTransferTransactions.listByTransfer(db, transfer.id) const source = transactions.find((transaction) => transaction.role === 'source') const catalog = await Catalog.read(db) const destinationChain = catalog.chainsByKey.get(transfer.snapshot.destinationChain.id) const sourceChain = catalog.chainsByKey.get(transfer.snapshot.sourceChain.id) const destinationAmount = transfer.snapshot.destinationAmount if ( !state.success || !source || !Hash.validate(source.transactionRef) || !destinationChain || !destinationAmount || !sourceChain || !transfer.snapshot.sender ) throw new EvidenceError('Registered Stargate transfer evidence is incomplete.') const delivery = await Stargate.verifyDestinationDelivery({ configuration: state.data.routeConfiguration, destinationAmount: destinationAmount.baseUnits, destinationBlockNumber: state.data.destinationBlockNumber, destinationChain, destinationTokenAddress: transfer.snapshot.destinationToken.address, fetch, guid: state.data.stargate.guid, recipient: transfer.snapshot.recipient, sourceChainId: transfer.snapshot.sourceChain.id, sourceTokenAddress: transfer.snapshot.sourceToken.address, }) if (delivery.type === 'invalid') throw new EvidenceError('Tempo delivery conflicts with registered transfer terms.') if (delivery.type === 'pending') { const verified = await Stargate.verifySourceTransaction({ configuration: state.data.routeConfiguration, destinationTokenAddress: transfer.snapshot.destinationToken.address, fetch, recipient: transfer.snapshot.recipient, sender: transfer.snapshot.sender, sourceAmount: transfer.snapshot.sourceAmount.baseUnits, sourceBlock: state.data.stargate.sourceBlock ? { hash: state.data.stargate.sourceBlock.hash, number: BigInt(state.data.stargate.sourceBlock.number), } : undefined, sourceChain, sourceTokenAddress: transfer.snapshot.sourceToken.address, transactionHash: source.transactionRef, validAfter: transfer.snapshot.quote.sampledAt, }) if (verified.type === 'reorged') { const reopened = await Transfer.reopenSourceTransaction(db, { chainId: transfer.snapshot.sourceChain.id, id: transfer.id, providerState: { destinationBlockNumber: state.data.destinationBlockNumber, routeConfiguration: state.data.routeConfiguration, }, transactionHash: source.transactionRef, }) if (reopened.type !== 'reopened') { record('invalid_state') return { type: 'ignored' } } if (options.dispatchTransferUpdates && reopened.references.length > 0) await options.dispatchTransferUpdates(reopened.references) record('reopened') return { type: 'reopened' } } if (verified.type === 'pending') return followUp(message, options, record) if ( verified.type === 'invalid' || verified.guid.toLowerCase() !== state.data.stargate.guid.toLowerCase() || verified.sourceEid !== state.data.stargate.sourceEid ) throw new EvidenceError('Source transaction conflicts with registered Stargate evidence.') return followUp(message, options, record) } const completed = await Transfer.complete(db, { chainId: transfer.snapshot.destinationChain.id, destinationAmount, id: transfer.id, transactionHash: delivery.transactionHash, }) if (completed.type === 'invalid') throw new EvidenceError('Funding transfer cannot accept destination evidence.') if (completed.type === 'not_found') { record('not_found') return { type: 'ignored' } } if ( completed.type === 'completed' && options.dispatchTransferUpdates && completed.references.length > 0 ) await options.dispatchTransferUpdates(completed.references) record(completed.type) return { type: 'completed' } }, async tick() { if (!options.dispatch) throw new DispatchError() const db = Db.get(options.db) let records = await FundingTransfers.listProcessing(db, { cursor: sweepCursor, limit: sweepLimit, providerId: 'stargate', }) if (records.length === 0 && sweepCursor !== undefined) { sweepCursor = undefined records = await FundingTransfers.listProcessing(db, { limit: sweepLimit, providerId: 'stargate', }) } let checked = 0 for (const transfer of records) { await options.dispatch( { transferId: transfer.id, type: 'funding:transfer:reconcile', }, { delaySeconds: 0 }, ) sweepCursor = transfer.id checked++ } record('swept', checked) return { checked } }, } } export declare namespace createTracker { /** Tracker dependencies and queue dispatch. */ type Options = { /** Authoritative database or per-invocation database factory. */ db: Db.Source /** Enqueues one delayed reconciliation follow-up. */ dispatch?: ((message: Message, options: { delaySeconds: number }) => Promise) | undefined /** Enqueues webhook updates staged with transfer mutations. */ dispatchTransferUpdates?: | ((references: readonly Webhooks.QueueReference[]) => Promise) | undefined /** Fetch implementation override for chain RPC requests. */ fetch?: Provider.Fetch | undefined /** Optional bounded operational metrics. */ metrics?: Metrics.Metrics | undefined } /** Reconciles one registered funding transfer. */ type Tracker = { /** Verifies or defers one destination delivery. */ reconcile(message: Message): Promise /** Enqueues processing transfers whose initial dispatch may have failed. */ tick(): Promise<{ checked: number }> } /** One reconciliation disposition. */ type Result = | { type: 'completed' } | { type: 'ignored' } | { type: 'pending' } | { type: 'reopened' } } async function followUp( message: Message, options: createTracker.Options, record: (outcome: string) => void, ): Promise { const attempt = (message.attempt ?? 0) + 1 if (attempt > maximumFollowUpAttempts) { record('exhausted') throw new ExhaustedError() } if (!options.dispatch) throw new DispatchError() await options.dispatch( { attempt, transferId: message.transferId, type: 'funding:transfer:reconcile' }, { delaySeconds: followUpDelaySeconds }, ) record('pending') return { type: 'pending' } } class DispatchError extends Error { override name = 'Funding.TransferReconciliation.DispatchError' } class EvidenceError extends Error { override name = 'Funding.TransferReconciliation.EvidenceError' } class ExhaustedError extends Error { override name = 'Funding.TransferReconciliation.ExhaustedError' }