import { AbiEvent, Address } from 'ox' import { getBlockNumber, getLogs } from 'viem/actions' import type * as Db from '../../db/Db.js' import * as FundingDepositAddresses from '../../db/tables/fundingDepositAddresses.js' import * as FundingDeposits from '../../db/tables/fundingDeposits.js' import * as Viem from '../Viem.js' import * as Value from '../Value.js' import * as Deposit from './Deposit.js' const addressBatchSize = 50 const defaultBackfillBlocks = 16n const defaultConfirmations = 1 const defaultMaximumBlocks = 16n const transferEvent = AbiEvent.from( 'event Transfer(address indexed from, address indexed to, uint256 value)', ) /** Active reusable address watched by one chain-and-token observer shard. */ export type Route = { /** Provider-owned reusable address. */ address: string /** Funding deposit address id. */ id: string } /** Verified source transfer discovered directly from an EVM log. */ export type Observation = { /** Funding deposit address that received the transfer. */ addressId: string /** Source token quantity in base units. */ amount: string /** Confirmed source block containing the transfer. */ blockNumber: bigint /** Source chain CAIP-2 id. */ chainId: string /** Source-chain sender. */ sender: string /** Source token contract. */ tokenAddress: string /** Source transaction hash. */ transactionHash: string /** Transfer log position within the source transaction. */ transferIndex: number } /** Scans confirmed EVM blocks for transfers into active reusable addresses. */ export async function scan(options: scan.Options): Promise { if (options.routes.length === 0) return { cursor: options.cursor ?? 0n, head: options.cursor ?? 0n, observations: [] } if (!Address.validate(options.tokenAddress, { strict: false })) throw new ConfigurationError('source token address') if (options.rpcUrls.length === 0) throw new ConfigurationError('source RPC URL') const client = Viem.createEvmClient({ fetch: options.fetch ?? globalThis.fetch, urls: options.rpcUrls, }) const head = await getBlockNumber(client, { cacheTime: 0 }) const confirmations = BigInt(options.confirmations ?? defaultConfirmations) const confirmed = head >= confirmations ? head - confirmations + 1n : 0n const backfillBlocks = options.backfillBlocks ?? defaultBackfillBlocks const fromBlock = (() => { if (options.cursor !== undefined) return options.cursor + 1n if (confirmed >= backfillBlocks) return confirmed - backfillBlocks + 1n return 0n })() if (fromBlock > confirmed) return { cursor: options.cursor ?? confirmed, head: confirmed, observations: [] } const maximumBlocks = options.maximumBlocks ?? defaultMaximumBlocks const toBlock = minimum(confirmed, fromBlock + maximumBlocks - 1n) const routesByAddress = new Map( options.routes.map((route) => { if (!Address.validate(route.address, { strict: false })) throw new ConfigurationError('deposit address') return [route.address.toLowerCase(), route] as const }), ) const addresses = [...routesByAddress.keys()] as Address.Address[] const observations: Observation[] = [] for (let index = 0; index < addresses.length; index += addressBatchSize) { const batch = addresses.slice(index, index + addressBatchSize) const logs = await getLogs(client, { address: options.tokenAddress as Address.Address, args: { to: batch }, event: transferEvent, fromBlock, strict: true, toBlock, }) for (const log of logs) { if (log.blockNumber === null || log.logIndex === null || !log.transactionHash) continue const event = (() => { try { return AbiEvent.decode(transferEvent, { data: log.data, topics: log.topics }) } catch { return undefined } })() const route = event ? routesByAddress.get(event.to.toLowerCase()) : undefined if (!event || !route) continue observations.push({ addressId: route.id, amount: event.value.toString(), blockNumber: log.blockNumber, chainId: options.chainId, sender: event.from.toLowerCase(), tokenAddress: options.tokenAddress.toLowerCase(), transactionHash: log.transactionHash, transferIndex: log.logIndex, }) } } observations.sort( (left, right) => Number(left.blockNumber - right.blockNumber) || left.transferIndex - right.transferIndex, ) return { cursor: toBlock, head: confirmed, observations } } export declare namespace scan { /** EVM shard state and scan bounds. */ type Options = { /** Initial confirmed-block backfill when no cursor exists. */ backfillBlocks?: bigint | undefined /** Source chain CAIP-2 id. */ chainId: string /** Required block confirmations. */ confirmations?: number | undefined /** Last confirmed block processed by the shard. */ cursor?: bigint | undefined /** Fetch implementation override. */ fetch?: typeof globalThis.fetch | undefined /** Maximum confirmed blocks processed in one invocation. */ maximumBlocks?: bigint | undefined /** Active reusable addresses in the shard. */ routes: readonly Route[] /** Ordered source-chain RPC endpoints. */ rpcUrls: readonly string[] /** Source token contract. */ tokenAddress: string } /** Confirmed cursor and verified transfers produced by one scan. */ type Result = { /** Last confirmed block processed by the shard. */ cursor: bigint /** Latest block satisfying the confirmation policy. */ head: bigint /** Verified transfers into active addresses. */ observations: readonly Observation[] } } /** Lists the active Relay addresses assigned to one source observer shard. */ export async function listRoutes( db: Db.Db, options: listRoutes.Options, ): Promise { const records = await FundingDepositAddresses.listActiveSources(db, options) return records.map((record) => ({ address: record.address, id: record.id })) } export declare namespace listRoutes { /** Provider and source asset identifying one observer shard. */ type Options = { /** Funding provider that owns the deposit addresses. */ providerId: string /** Source chain CAIP-2 id. */ sourceChainId: string /** Source token key. */ sourceTokenKey: string } } /** Persists one verified source transfer before provider indexing. */ export async function observe(db: Db.Db, input: observe.Input): Promise { const address = await FundingDepositAddresses.get(db, input.addressId) if ( !address || address.providerId !== 'relay' || address.status !== 'active' || address.snapshot.sourceChain.id !== input.chainId || !Address.isEqual( address.snapshot.sourceToken.address as Address.Address, input.tokenAddress as Address.Address, ) || !Address.isEqual(address.address as Address.Address, input.recipient as Address.Address) ) return { type: 'ignored' } return FundingDeposits.withSourceTransaction(db, { depositAddressId: address.id, fn: async (tx) => { const existing = await FundingDeposits.listBySourceTransaction(tx, { depositAddressId: address.id, sourceTransactionHash: input.transactionHash, }) const record = existing.find((candidate) => candidate.sourceTransferIndex === input.transferIndex) ?? existing.find((candidate) => candidate.sourceTransferIndex === null) if (record) return { record, type: 'existing' as const } return Deposit.createFromSource(tx, { now: input.now, snapshot: { ...address.snapshot, depositAddressId: address.id, sender: input.sender, sourceAmount: Value.tokenAmount({ baseUnits: input.amount, currency: address.snapshot.sourceToken.currency, decimals: address.snapshot.sourceToken.decimals, }), sourceTransactionHashes: [input.transactionHash], }, sourceTransactionHash: input.transactionHash, sourceTransferIndex: input.transferIndex, }) }, sourceTransactionHash: input.transactionHash, }) } export declare namespace observe { /** Verified source transfer and route identity. */ type Input = { /** Funding deposit address id. */ addressId: string /** Source token quantity in base units. */ amount: string /** Source chain CAIP-2 id. */ chainId: string /** Detection time override. */ now?: Date | undefined /** Provider-owned reusable address that received the transfer. */ recipient: string /** Source-chain sender. */ sender: string /** Source token contract. */ tokenAddress: string /** Source transaction hash. */ transactionHash: string /** Transfer log position within the source transaction. */ transferIndex: number } /** Persistence outcome for one chain-observed source transfer. */ type Result = | { record: FundingDeposits.Record; type: 'created' | 'existing' } | { type: 'ignored' } } function minimum(left: bigint, right: bigint) { return left < right ? left : right } class ConfigurationError extends Error { override name = 'FundingSourceObservation.ConfigurationError' constructor(field: string) { super(`Invalid ${field} configured for funding source observation.`) } }