import { AbiEvent, Address, Base58, Bytes, Hash, Hex, RpcSchema, RpcTransport } from 'ox' import { TransactionReceiptNotFoundError } from 'viem' import { getBlockNumber, getTransactionReceipt } from 'viem/actions' import * as z from 'zod/mini' import * as Db from '../../db/Db.js' import * as Viem from '../Viem.js' import * as Catalog from './Catalog.js' import * as Chain from './Chain.js' import type * as Reconciliation from './Reconciliation.js' const transferEvent = AbiEvent.from( 'event Transfer(address indexed from, address indexed to, uint256 value)', ) const transferTopic = AbiEvent.getSelector(transferEvent).slice(2) const catalogCacheTtlMs = 60_000 const defaultConfirmations = 1 type SolanaRpcSchema = RpcSchema.From<{ Request: { method: 'getTransaction' params: [ transactionHash: string, options: { commitment: 'finalized' encoding: 'jsonParsed' maxSupportedTransactionVersion: 0 }, ] } ReturnType: unknown }> namespace schema { const SolanaInstruction = z.object({ parsed: z.optional( z.object({ info: z.record(z.string(), z.unknown()), type: z.string(), }), ), program: z.optional(z.string()), }) export const SolanaTransaction = z.nullable( z.object({ meta: z.object({ err: z.nullable(z.unknown()), innerInstructions: z.optional( z.array(z.object({ instructions: z.array(SolanaInstruction) })), ), postTokenBalances: z.optional( z.array( z.object({ accountIndex: z.number().check(z.int(), z.nonnegative()), mint: z.string(), owner: z.optional(z.string()), }), ), ), }), transaction: z.object({ message: z.object({ accountKeys: z.array(z.union([z.string(), z.object({ pubkey: z.string() })])), instructions: z.array(SolanaInstruction), }), }), }), ) export const TronBlock = z.object({ block_header: z.object({ raw_data: z.object({ number: z.number().check(z.int()) }) }), }) export const TronReceipt = z.object({ blockNumber: z.optional(z.number().check(z.int(), z.nonnegative())), id: z.optional(z.string()), log: z.optional( z.array( z.object({ address: z.string(), data: z.string(), topics: z.array(z.string()), }), ), ), receipt: z.optional(z.object({ result: z.optional(z.string()) })), }) } /** Resolves preferred evidence endpoints while retaining catalog URLs as fallbacks. */ export function resolveRpcUrls(options: resolveRpcUrls.Options): readonly string[] { const match = /^eip155:(\d+)$/.exec(options.chain.id) if (!match) return options.chain.rpcUrls const chainId = Number(match[1]) as Viem.ChainId const configuredChainIds = Viem.configuredChainIds(options.rpcUrlEnv) const override = (() => { if (configuredChainIds.length > 0) { if (configuredChainIds.includes(chainId)) return Viem.resolveUrl(options.rpcUrlEnv, chainId) return undefined } if (chainId === options.defaultChainId) return Viem.resolveUrl(options.rpcUrlEnv, chainId) return undefined })() if (!override) return options.chain.rpcUrls return [override, ...options.chain.rpcUrls.filter((url) => url !== override)] } export declare namespace resolveRpcUrls { /** Catalog chain and Worker override used to resolve evidence endpoints. */ type Options = { /** Funding chain with catalog-owned fallback endpoints. */ chain: Chain.Chain /** Tempo chain that receives a shared RPC URL override. */ defaultChainId: Viem.ChainId /** Shared or per-chain RPC URL environment value configured by the Worker. */ rpcUrlEnv?: Viem.UrlResolver | undefined } } /** Creates a token-transfer verifier for EVM, Solana, and Tron chains. */ export function createVerifier(options: createVerifier.Options): Reconciliation.VerifyTransfers { const fetch = options.fetch ?? globalThis.fetch // Delay this shared read until reconciliation releases its claim transaction. // Cache successes briefly so catalog changes reach live isolates; failed reads reset immediately. let urlsExpiresAt = 0 let urlsByChainId: Promise> | undefined const getUrlsByChainId = () => { if (urlsByChainId && Date.now() < urlsExpiresAt) return urlsByChainId urlsExpiresAt = Date.now() + catalogCacheTtlMs urlsByChainId = Catalog.read(Db.get(options.db)) .then( (catalog) => new Map( [...catalog.chainsByKey.values()].map( (chain) => [ chain.id, resolveRpcUrls({ chain, defaultChainId: options.defaultChainId ?? Viem.defaultChainId, rpcUrlEnv: options.rpcUrlEnv, }), ] as const, ), ), ) .catch((cause) => { urlsExpiresAt = 0 urlsByChainId = undefined throw cause }) return urlsByChainId } return async (parameters) => { const urls = (await getUrlsByChainId()).get(parameters.chain.id) if (!urls || urls.length === 0) throw new ChainConfigurationError(parameters.chain.id) if (parameters.chain.kind === 'evm') return verifyEvm(parameters, { confirmations: options.confirmations?.[parameters.chain.id] ?? defaultConfirmations, fetch, urls, }) if (parameters.chain.kind === 'solana') return verifySolana(parameters, { fetch, urls }) return verifyTron(parameters, { confirmations: options.confirmations?.[parameters.chain.id] ?? defaultConfirmations, fetch, urls, }) } } export declare namespace createVerifier { /** Chain clients and finality bounds used for funding evidence. */ type Options = { /** Per-chain confirmation overrides keyed by CAIP-2 id. */ confirmations?: Readonly> | undefined /** Authoritative database or per-invocation database factory. */ db: Db.Source /** Tempo chain that receives a shared RPC URL override. */ defaultChainId?: Viem.ChainId | undefined /** Fetch implementation override for tests. */ fetch?: typeof globalThis.fetch | undefined /** Shared or per-EVM-chain RPC URL environment value. */ rpcUrlEnv?: Viem.UrlResolver | undefined } } type FetchOptions = { fetch: typeof globalThis.fetch urls: readonly string[] } async function verifyEvm( parameters: Reconciliation.VerifyTransfers.Parameters, options: FetchOptions & { confirmations: number }, ): Promise { const client = Viem.createEvmClient(options) const [receipt, head] = await Promise.all([ getTransactionReceipt(client, { hash: parameters.transactionHash as Hex.Hex }).catch( (cause) => { if (cause instanceof TransactionReceiptNotFoundError) return null throw cause }, ), getBlockNumber(client, { cacheTime: 0 }), ]) if (!receipt || receipt.status !== 'success' || receipt.blockNumber === null) return [] if (head - receipt.blockNumber + 1n < BigInt(options.confirmations)) return [] const token = parameters.token.address as Address.Address const recipient = parameters.recipient as Address.Address return receipt.logs.flatMap((log) => { if (!Address.isEqual(log.address, token) || log.logIndex === null) return [] const event = (() => { try { return AbiEvent.decode(transferEvent, { data: log.data, topics: log.topics }) } catch { return undefined } })() if (!event || !Address.isEqual(event.to, recipient)) return [] return [ { amount: event.value.toString(), sender: event.from.toLowerCase(), transactionHash: parameters.transactionHash, transferIndex: log.logIndex, }, ] }) } async function verifySolana( parameters: Reconciliation.VerifyTransfers.Parameters, options: FetchOptions, ): Promise { const transaction = schema.SolanaTransaction.parse( await requestSolana(options, { method: 'getTransaction', params: [ parameters.transactionHash, { commitment: 'finalized', encoding: 'jsonParsed', maxSupportedTransactionVersion: 0 }, ], }), ) if (!transaction || transaction.meta.err !== null) return [] const { message } = transaction.transaction const accounts = message.accountKeys.map((account) => typeof account === 'string' ? account : account.pubkey, ) const recipientAccounts = new Set( (transaction.meta.postTokenBalances ?? []) .filter( (balance) => balance.mint === parameters.token.address && balance.owner === parameters.recipient, ) .map((balance) => accounts[balance.accountIndex]) .filter((account): account is string => account !== undefined), ) recipientAccounts.add(parameters.recipient) const instructions = [ ...message.instructions, ...(transaction.meta.innerInstructions ?? []).flatMap((group) => group.instructions), ] return instructions.flatMap((instruction, transferIndex) => { if (instruction.program !== 'spl-token' || !instruction.parsed) return [] const { info, type } = instruction.parsed if (type !== 'transfer' && type !== 'transferChecked') return [] const destination = typeof info['destination'] === 'string' ? info['destination'] : undefined const authority = typeof info['authority'] === 'string' ? info['authority'] : undefined const mint = typeof info['mint'] === 'string' ? info['mint'] : parameters.token.address const amount = (() => { if (typeof info['amount'] === 'string') return info['amount'] const tokenAmount = info['tokenAmount'] if (tokenAmount && typeof tokenAmount === 'object' && 'amount' in tokenAmount) return typeof tokenAmount.amount === 'string' ? tokenAmount.amount : undefined return undefined })() if ( !destination || !recipientAccounts.has(destination) || mint !== parameters.token.address || !amount ) return [] return [ { amount, ...(authority ? { sender: authority } : {}), transactionHash: parameters.transactionHash, transferIndex, }, ] }) } async function verifyTron( parameters: Reconciliation.VerifyTransfers.Parameters, options: FetchOptions & { confirmations: number }, ): Promise { const [receipt, block] = await Promise.all([ post( options, 'walletsolidity/gettransactioninfobyid', { value: parameters.transactionHash }, schema.TronReceipt, ), post(options, 'walletsolidity/getnowblock', {}, schema.TronBlock), ]) if ( receipt.receipt?.result !== 'SUCCESS' || receipt.blockNumber === undefined || block.block_header.raw_data.number - receipt.blockNumber + 1 < options.confirmations ) return [] const token = tronHex(parameters.token.address).slice(-40).toLowerCase() const recipient = tronHex(parameters.recipient).slice(-40).toLowerCase() return (receipt.log ?? []).flatMap((log, transferIndex) => { const [topic, sender, destination] = log.topics if ( log.address.toLowerCase().replace(/^0x/, '') !== token || topic?.toLowerCase().replace(/^0x/, '') !== transferTopic || destination?.toLowerCase().slice(-40) !== recipient ) return [] return [ { amount: BigInt(`0x${log.data.replace(/^0x/, '')}`).toString(), ...(sender ? { sender: tronAddress(`41${sender.slice(-40)}`) } : {}), transactionHash: parameters.transactionHash, transferIndex, }, ] }) } async function requestSolana( options: FetchOptions, request: SolanaRpcSchema['Request'], ): Promise { let cause: unknown for (const rpcUrl of options.urls) { try { return await RpcTransport.fromHttp(rpcUrl, { fetchFn: options.fetch, }).request(request) } catch (error) { cause = error } } throw cause ?? new ChainResponseError('No funding evidence endpoint is configured.') } async function post( options: FetchOptions, pathname: string, body: unknown, result: result, ) { let cause: unknown for (const rpcUrl of options.urls) { try { const url = new URL(rpcUrl) url.pathname = `${url.pathname.replace(/\/$/, '')}/${pathname}` const response = await options.fetch(url, { body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, method: 'POST', }) if (!response.ok) throw new ChainResponseError(`HTTP ${response.status}`) return result.parse(await response.json()) } catch (error) { cause = error } } throw cause ?? new ChainResponseError('No funding evidence endpoint is configured.') } function tronHex(address: string) { const bytes = Base58.toBytes(address) if (bytes.length !== 25 || bytes[0] !== 0x41) throw new ChainPayloadError() const payload = bytes.slice(0, 21) const checksum = Hash.sha256(Hash.sha256(payload), { as: 'Bytes' }).slice(0, 4) if (!Bytes.isEqual(bytes.slice(21), checksum)) throw new ChainPayloadError() return Hex.fromBytes(payload).slice(2) } function tronAddress(hex: string) { const payload = Hex.toBytes(`0x${hex}`) const checksum = Hash.sha256(Hash.sha256(payload), { as: 'Bytes' }).slice(0, 4) const bytes = new Uint8Array(payload.length + checksum.length) bytes.set(payload) bytes.set(checksum, payload.length) return Base58.fromBytes(bytes) } /** No authoritative chain endpoint is configured for a funding route. */ export class ChainConfigurationError extends Error { override name = 'Funding.Evidence.ChainConfigurationError' constructor(chainId: string) { super(`No funding evidence endpoint is configured for ${chainId}.`) } } /** An authoritative chain endpoint returned an invalid payload. */ export class ChainPayloadError extends Error { override name = 'Funding.Evidence.ChainPayloadError' } /** An authoritative chain endpoint returned an unsuccessful response. */ export class ChainResponseError extends Error { override name = 'Funding.Evidence.ChainResponseError' }