import type { Hex } from 'ox' import { TxEnvelopeTempo } from 'ox/tempo' import { type Address, type Client, type EIP1193RequestFn, type TransactionReceipt, TransactionReceiptNotFoundError, } from 'viem' import { getTransactionReceipt } from 'viem/actions' import * as Db from '../../db/Db.js' import * as BillingSettings from '../../db/tables/billingSettings.js' import * as Projects from '../../db/tables/projects.js' import * as Fees from '../../internal/Fees.js' import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js' import * as Tidx from '../../internal/Tidx.js' import * as Viem from '../../internal/Viem.js' import * as Billing from '../management/Billing.js' /** * Runs one finalization pass over pending sponsored transactions: rows with a * receipt finalize with the actual fee (`gasUsed × effectiveGasPrice`, in * fee-token base units); rows still unmined past the pending TTL fail, as do * hash-less fill intents the TTL outlives. Rows whose receipt lookup errors * persist past the error TTL fail so they cannot monopolize every batch. * * @param db - The database holding `sponsored_transactions`. * @param options - Finalization options. * @returns Counters for the pass. */ export async function finalize(db: Db.Db, options: finalize.Options): Promise { const { batchSize = 100, getClient, now = () => new Date(), pendingTtlMs = 3_600_000, receiptErrorTtlMs = 86_400_000, } = options const rows = await SponsoredTransactions.listPending(db, { limit: batchSize }) const result = { errors: 0, failed: 0, finalized: 0, pending: 0 } for (const row of rows) { // Fill intents carry no hash until reconciliation matches them on-chain; // here only the TTL can settle them. if (row.transactionHash === null) { if (Date.parse(row.createdAt) + pendingTtlMs <= now().getTime()) { await SponsoredTransactions.fail(db, row.id, now().toISOString()) result.failed++ } else result.pending++ continue } let receipt: TransactionReceipt | null try { receipt = await getTransactionReceipt(getClient(row.chainId), { hash: row.transactionHash as Hex.Hex, }) } catch (error) { // Unmined transactions surface as a typed not-found; let the TTL decide. if (error instanceof TransactionReceiptNotFoundError) receipt = null else { result.errors++ if (Date.parse(row.createdAt) + receiptErrorTtlMs <= now().getTime()) { await SponsoredTransactions.fail(db, row.id, now().toISOString()) result.failed++ } else result.pending++ continue } } if (receipt) { // A reverted transaction still finalizes: the fee payer paid its fees. const feeAmount = Fees.fromGas(receipt.gasUsed, receipt.effectiveGasPrice) await SponsoredTransactions.finalize(db, row.id, { feeAmount: feeAmount.toString(), finalizedAt: now().toISOString(), }) result.finalized++ } else if (Date.parse(row.createdAt) + pendingTtlMs <= now().getTime()) { await SponsoredTransactions.fail(db, row.id, now().toISOString()) result.failed++ } else result.pending++ } return result } export declare namespace finalize { /** Options for {@link finalize}. */ type Options = { /** Maximum pending rows processed per pass. @default 100 */ batchSize?: number | undefined /** Client resolver keyed by the row's chain id. */ getClient: (chainId: number) => Client /** Clock; injectable for tests. */ now?: (() => Date) | undefined /** Age after which an unmined sponsorship fails. @default 1 hour */ pendingTtlMs?: number | undefined /** Age after which repeated receipt lookup errors fail a row. @default 24 hours */ receiptErrorTtlMs?: number | undefined } /** Counters returned by one finalization pass. */ type Result = { /** Receipt lookup errors that prevented finalization work. */ errors: number /** Rows marked failed (unmined past the pending TTL). */ failed: number /** Rows finalized with an actual fee. */ finalized: number /** Rows left pending (unmined, or receipt lookup errored within the error TTL). */ pending: number } } /** * Runs one reconciliation pass over pending fill intents (rows with no * transaction hash): fetches the fee payer's recent on-chain transactions * from TIDX, recomputes each candidate's fee-payer sign payload, and fills * matching intents' hashes so the receipt pass can finalize them. Query or * candidate errors leave intents for the next pass. * * @param db - The database holding `sponsored_transactions`. * @param options - Reconciliation options. * @returns Counters for the pass. */ export async function reconcile(db: Db.Db, options: reconcile.Options): Promise { const { batchSize = 100, candidateCap = 500, failedIntentTtlMs = SponsoredTransactions.failedIntentRecoveryTtlMs, feePayer, getClient, getTidx, skewMs = 120_000, } = options const intents = await SponsoredTransactions.listIntents(db, { failedSince: new Date(Date.now() - failedIntentTtlMs).toISOString(), limit: batchSize, }) const result = { errors: 0, matched: 0, scanned: 0 } if (intents.length === 0) return result const chains = new Map() for (const intent of intents) chains.set(intent.chainId, [...(chains.get(intent.chainId) ?? []), intent]) // prettier-ignore for (const [chainId, rows] of chains) { const bySignPayload = new Map(rows.map((row) => [row.signPayload, row])) // The intent set bounds the scan window: no cursor state to persist. const since = new Date( Math.min(...rows.map((row) => Date.parse(row.createdAt))) - skewMs, ).toISOString() let candidates: readonly globalThis.Record[] try { // Dynamic filter values; the tidx client's query type wants literals. const fetched = await getTidx(chainId).fetch({ chainId, query: `SELECT hash, "from" FROM txs WHERE fee_payer = '${feePayer.toLowerCase()}' AND block_timestamp >= '${since}' ORDER BY block_timestamp ASC LIMIT ${candidateCap}` as string, }) candidates = fetched.rows } catch { // Indexer outage must not fail intents; retry on the next pass. result.errors++ continue } for (const candidate of candidates) { if (bySignPayload.size === 0) break const hash = candidate['hash'] as Hex.Hex | undefined const sender = candidate['from'] as Address | undefined if (!hash || !sender) continue result.scanned++ try { const serialized = await getRawTransaction(getClient(chainId), hash) if (!serialized?.startsWith(TxEnvelopeTempo.serializedType)) continue const signPayload = TxEnvelopeTempo.getFeePayerSignPayload( TxEnvelopeTempo.deserialize(serialized as TxEnvelopeTempo.Serialized), { sender }, ) const intent = bySignPayload.get(signPayload) if (!intent) continue await SponsoredTransactions.assignTransactionHash( db, intent.id, hash, await options.limitFor?.(intent), ) bySignPayload.delete(signPayload) result.matched++ } catch (error) { // Spend-limit enforcement is an expected policy refusal; dependency, // lookup, and malformed-envelope failures remain operational errors. if (!(error instanceof SponsoredTransactions.PeriodSpendLimitError)) result.errors++ } } } return result } export declare namespace reconcile { /** Options for {@link reconcile}. */ type Options = { /** Maximum intents considered per pass. @default 100 */ batchSize?: number | undefined /** Maximum on-chain candidates fetched per chain per pass. @default 500 */ candidateCap?: number | undefined /** How long failed intents remain reconciliation candidates. @default 24 hours */ failedIntentTtlMs?: number | undefined /** Fee payer address whose on-chain transactions are candidates. */ feePayer: Address /** Client resolver keyed by the intent's chain id. */ getClient: (chainId: number) => Client /** TIDX client resolver keyed by the intent's chain id. */ getTidx: (chainId: number) => Tidx.Client /** Resolves the current period spend limit before reopening a failed intent. */ limitFor?: | (( intent: SponsoredTransactions.Record, ) => Promise) | undefined /** Lookback slack before the oldest intent's creation. @default 2 minutes */ skewMs?: number | undefined } /** Counters returned by one reconciliation pass. */ type Result = { /** Dependency or candidate errors that prevented reconciliation work. */ errors: number /** Intents matched to an on-chain transaction. */ matched: number /** On-chain candidates inspected. */ scanned: number } } /** * Fetches a mined transaction's canonical envelope bytes. * `eth_getRawTransactionByHash` is a node extension absent from viem's typed * RPC schema, hence the request-fn narrowing. */ function getRawTransaction(client: Client, hash: Hex.Hex) { type Request = EIP1193RequestFn< [ { Method: 'eth_getRawTransactionByHash' Parameters: readonly [Hex.Hex] ReturnType: Hex.Hex | null }, ] > return (client.request as Request)({ method: 'eth_getRawTransactionByHash', params: [hash] }) } /** * Builds a scheduled sponsorship finalizer bound to a database and RPC config. * A host runtime drives {@link createFinalizer.Finalizer.tick} on a timer (a * Cloudflare `scheduled` cron handler, a self-host `setInterval`, …); each * tick reconciles fill intents (when `feePayer` + `tidx` are configured) and * runs one {@link finalize} pass, with memoized per-chain clients. */ export function createFinalizer(options: createFinalizer.Options): createFinalizer.Finalizer { const clients = new Map() const getClient = (chainId: number) => { let client = clients.get(chainId) if (!client) { client = Viem.getClient({ chainId: chainId as Viem.ChainId, rpc: options.rpc }) clients.set(chainId, client) } return client } const getTidx = options.tidx ? Tidx.createGetClient({ defaultChainId: options.defaultChainId, tidx: options.tidx }) : undefined const feePayer = options.feePayer return { async tick() { // Resolve per tick so Workers factories build a fresh Hyperdrive-pooled // connection each run. const db = Db.get(options.db) // Reconcile first so freshly matched intents finalize in the same tick. const reconciled = feePayer && getTidx ? await reconcile(db, { batchSize: options.batchSize, feePayer, getClient, getTidx: (chainId) => getTidx(chainId as Viem.ChainId), limitFor: async (intent) => { if (!Viem.isMainnet(intent.chainId)) return undefined if (!intent.billable) { if (!intent.projectId) return undefined const project = await Projects.get(db, intent.projectId) // Missing promotion state cannot prove the intent remains within Tempo's budget. if (!project || project.sponsorshipSubsidyStartsAt === null) return { billable: false, chainIds: [Viem.chainId.mainnet], max: -1n, projectId: intent.projectId, since: intent.createdAt, } if (project.sponsorshipSpendLimit === null) return undefined return { billable: false, chainIds: [Viem.chainId.mainnet], max: BigInt(project.sponsorshipSpendLimit), projectId: intent.projectId, since: project.sponsorshipSubsidyStartsAt, } } if (!intent.billable) return undefined const settings = await BillingSettings.get(db, intent.orgId) if (settings?.spendLimit == null) return undefined return { chainIds: [Viem.chainId.mainnet], max: Billing.toBaseUnits(settings.spendLimit), since: Billing.periodStart(settings.period), } }, }) : { errors: 0, matched: 0, scanned: 0 } const finalized = await finalize(db, { batchSize: options.batchSize, getClient, now: options.now, pendingTtlMs: options.pendingTtlMs, }) return { ...finalized, ...reconciled, errors: finalized.errors + reconciled.errors, } }, } } export declare namespace createFinalizer { /** Options for {@link createFinalizer}. */ type Options = Pick & { /** Database holding sponsorship rows, or a factory resolved per tick (Workers/Hyperdrive). */ db: Db.Source /** Default chain id for the TIDX client resolver. */ defaultChainId?: Viem.ChainId | undefined /** Fee payer address whose on-chain transactions reconcile fill intents; omit to skip reconciliation. */ feePayer?: Address | undefined /** RPC options for the receipt-lookup clients. */ rpc?: Viem.getClient.Rpc | undefined /** TIDX query client options for intent reconciliation; omit to skip reconciliation. */ tidx?: Tidx.getClient.Tidx | undefined } /** A scheduled sponsorship finalizer. */ type Finalizer = { /** Runs one reconcile + finalize pass. */ tick(): Promise } }