import { AbiEvent, AbiFunction, Address, Base58, Bytes, Hash, Hex } from 'ox' import { getBlockNumber, getLogs, getTransaction, getTransactionReceipt } from 'viem/actions' import * as z from 'zod/mini' import * as Db from '../../../db/Db.js' import * as FundingDepositAddresses from '../../../db/tables/fundingDepositAddresses.js' import * as FundingDepositRequestObservations from '../../../db/tables/fundingDepositRequestObservations.js' import * as Viem from '../../Viem.js' import * as FundingCatalog from '../Catalog.js' import * as FundingChain from '../Chain.js' import * as Evidence from '../Evidence.js' import * as FundingProvider from '../Provider.js' import * as Subsidy from '../Subsidy.js' const defaultBaseUrl = 'https://api.relay.link' const defaultFetch: FundingProvider.Fetch = (input, init) => globalThis.fetch(input, init) const deliveryBackfillBlocks = 256n const relayChainsCacheTtlMs = 60_000 const relayRefundStatuses = ['refund', 'refunded'] as const const sourceSweepForwardBlocks = 256n const depositErc20FullAllowance = AbiFunction.from( 'function depositErc20(address depositor,address token,bytes32 id)', ) const depositErc20WithAmount = AbiFunction.from( 'function depositErc20(address depositor,address token,uint256 amount,bytes32 id)', ) const relayErc20DepositEvent = AbiEvent.from( 'event RelayErc20Deposit(address from, address token, uint256 amount, bytes32 id)', ) const relayTransfer = AbiFunction.from('function transfer(address recipient,uint256 amount)') const transferEvent = AbiEvent.from( 'event Transfer(address indexed from, address indexed to, uint256 value)', ) const webhookSignatureToleranceMs = 300_000 const deliveryObservationStatuses = [ 'depositing', 'pending', 'submitted', 'success', 'waiting', ] as const // Bound provider statuses before using them as metric tags. const webhookStatuses = [ 'depositing', 'failure', 'fallback', 'pending', 'refund', 'refunded', 'submitted', 'success', 'waiting', ] as const namespace schema { const Decimal = z.union([ z.string().check(z.regex(/^\d+(\.\d+)?$/)), z.pipe( z.number().check(z.nonnegative()), z.transform((value) => String(value)), ), ]) const Integer = z.union([ z.number().check(z.int(), z.nonnegative()), z.pipe( z.pipe( z.string().check(z.regex(/^\d+$/)), z.transform((value) => Number(value)), ), z.number().check(z.int(), z.nonnegative()), ), ]) export const Quote = z.object({ details: z.optional( z.object({ currencyOut: z.optional( z.object({ amount: z.optional(Decimal), minimumAmount: z.optional(Decimal), }), ), timeEstimate: z.optional(Integer), }), ), }) const StepCall = z.object({ chainId: Integer, data: z.string().check(z.regex(/^0x[0-9a-fA-F]*$/)), from: z.string(), to: z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/)), value: z.optional(z.string().check(z.regex(/^\d+$/))), }) const StepItem = z.object({ check: z.optional(z.object({ endpoint: z.string() })), data: StepCall, }) const Step = z.object({ depositAddress: z.optional(z.string()), id: z.string(), items: z.optional(z.array(StepItem)), kind: z.string(), requestId: z.optional(z.string()), }) export const PreparedQuote = z.object({ details: z.object({ currencyIn: z.object({ amount: Decimal }), currencyOut: z.object({ amount: Decimal, minimumAmount: Decimal }), timeEstimate: z.optional(Integer), }), fees: z.optional( z.object({ relayer: z.optional(z.object({ amount: Decimal })), }), ), steps: z.array(Step), }) export const DepositAddress = z.object({ details: PreparedQuote.shape.details, fees: PreparedQuote.shape.fees, steps: z.array( z.object({ depositAddress: z.optional(z.string()), items: z.optional( z.array( z.object({ check: z.optional(z.object({ endpoint: z.string() })), }), ), ), requestId: z.optional(z.string()), }), ), }) export const Chains = z.object({ chains: z.array( z.object({ id: Integer, protocol: z.optional( z.object({ v2: z.optional( z.object({ depository: z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/)), }), ), }), ), solverAddresses: z.optional(z.array(z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/)))), }), ), }) export const DeliveryObservation = z.object({ relay: z.object({ orderId: z.string().check(z.regex(/^0x[0-9a-fA-F]{64}$/)), sourceDepository: z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/)), sourceObservedAt: z.iso.datetime(), sourceSweepTransactionHash: z.string().check(z.regex(/^0x[0-9a-fA-F]{64}$/)), }), }) export const RequestCorrelation = z.object({ relay: z.object({ depositor: z.optional(z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/))), depository: z.optional(z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/))), orderId: z.optional(z.string().check(z.regex(/^0x[0-9a-fA-F]{64}$/))), solver: z.optional(z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/))), }), }) const RequestTransaction = z.object({ txHash: z.string().check(z.minLength(1)), }) export const Requests = z.object({ continuation: z.optional(z.string().check(z.minLength(1))), requests: z.array( z.object({ createdAt: z.iso.datetime(), data: z.optional( z.object({ outTxs: z.optional(z.array(RequestTransaction)), }), ), depositAddress: z.optional( z.nullable( z.object({ depositTxHash: z.optional(z.nullable(z.string().check(z.minLength(1)))), }), ), ), id: z.string().check(z.minLength(1)), refundCurrencyData: z.optional( z.nullable( z.object({ amount: z.string().check(z.regex(/^\d+$/)), }), ), ), protocol: z.optional( z.nullable( z.object({ deposit: z.optional( z.object({ origin: z.optional( z.object({ depositor: z.optional(z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/))), depository: z.optional(z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/))), }), ), }), ), orderId: z.optional(z.string().check(z.regex(/^0x[0-9a-fA-F]{64}$/))), solver: z.optional( z.object({ address: z.optional(z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/))), }), ), }), ), ), status: z.string().check(z.minLength(1)), updatedAt: z.iso.datetime(), }), ), }) export const WebhookPayload = z.object({ data: z.object({ depositAddress: z.nullable(z.object({ address: z.string().check(z.minLength(1)) })), requestId: z.optional(z.string().check(z.minLength(1))), status: z.string().check(z.minLength(1)), updatedAt: z.number().check(z.int(), z.nonnegative()), }), event: z.literal('request.status.updated'), }) } /** Validates a Relay request-list response and reports payload drift with field-level details. */ export function parseDepositAddressRequests(value: unknown) { const parsed = schema.Requests.safeParse(value) if (!parsed.success) throw new FundingProvider.ProviderPayloadError(z.prettifyError(parsed.error)) return parsed.data } /** Creates a Relay funding provider backed by `/quote/v2`. */ export function relay(options: relay.Options = {}): FundingProvider.Provider<'relay'> { const userAddresses = parseUserAddresses(options.userAddresses) if ( Object.entries(userAddresses).some(([chainId, address]) => invalidUserAddress(chainId, address)) ) throw new FundingProvider.ProviderConfigurationError() const endpoint = quoteEndpoint(options) const fetch = options.fetch ?? defaultFetch const now = options.now ?? (() => new Date()) const observeDepositAddressRequest = options.observation ? createDepositAddressObserver({ ...options.observation, ...(options.apiKey ? { apiKey: options.apiKey } : {}), ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), fetch, now, }) : undefined const webhook = (() => { if (!options.apiKey || !options.webhook) return undefined const { apiKey, webhook } = options const verify = (input: FundingProvider.Webhook.verify.Parameters) => verifyWebhook(webhookOptions(input, apiKey)) return { async receive(input: FundingProvider.Webhook.receive.Parameters) { const receivedAt = now().toISOString() const hint = (() => { try { return verify(input) } catch (cause) { webhook.onResult?.( cause instanceof FundingProvider.WebhookAuthenticationError ? 'authentication_failed' : 'payload_invalid', ) throw cause } })() if (!hint.address) { webhook.onResult?.('ignored') return { type: 'ignored' as const } } const database = Db.get(webhook.db) const address = await FundingDepositAddresses.getByProviderAddress(database, { address: hint.address, providerId: 'relay', }) if (!address) { webhook.onResult?.('unknown_address') return { type: 'unknown_address' as const } } if (hint.requestId) await FundingDepositRequestObservations.observeWebhook(database, { depositAddressId: address.id, providerRequestId: hint.requestId, receivedAt, }).catch((cause) => { webhook.onResult?.('observation_failed') throw cause }) try { const webhookTiming = (() => { if (!hint.providerStatus || !hint.providerUpdatedAt || !hint.requestId || !hint.sentAt) return undefined return { enqueuedAt: now().toISOString(), providerStatus: hint.providerStatus, providerUpdatedAt: hint.providerUpdatedAt, receivedAt, sentAt: hint.sentAt, } })() await webhook.dispatch({ addressId: address.id, ...(hint.requestId ? { requestId: hint.requestId } : {}), trigger: 'webhook', type: 'funding:deposit-address:reconcile', ...(webhookTiming ? { webhookTiming } : {}), }) } catch (cause) { webhook.onResult?.('enqueue_failed') throw cause } webhook.onResult?.('queued') return { addressId: address.id, type: 'queued' as const } }, verify, } })() return FundingProvider.from({ id: 'relay', name: 'Relay', ...(webhook ? { webhook } : {}), async createDepositAddress({ amount, candidate, recipient, refundAddress, subsidize }, signal) { if ( subsidize && (!options.subsidies || !Subsidy.supports(options.subsidies, { amount, destinationToken: candidate.destinationToken, sourceToken: candidate.sourceToken, })) ) throw new FundingProvider.ProviderUnavailableError() const quote = await (async () => { try { return await FundingProvider.requestJson(endpoint, { body: { amount, destinationChainId: relayChainId(candidate.route.destination.chain), destinationCurrency: candidate.destinationToken.address, originChainId: relayChainId(candidate.route.source.chain), originCurrency: candidate.sourceToken.address, recipient, refundTo: refundAddress, subsidizeFees: subsidize, tradeType: 'EXACT_INPUT', useDepositAddress: true, user: refundAddress, }, fetch, headers: options.apiKey ? { 'x-api-key': options.apiKey } : undefined, method: 'POST', signal, }) } catch (cause) { if (FundingProvider.unavailable(cause, ['NO_QUOTES', 'NO_ROUTE', 'UNSUPPORTED_ROUTE'])) throw new FundingProvider.ProviderUnavailableError() throw cause } })() const parsed = schema.DepositAddress.safeParse(quote) if (!parsed.success) throw new FundingProvider.ProviderPayloadError() if (parsed.data.details.currencyIn.amount !== amount) throw new FundingProvider.ProviderPayloadError() const step = parsed.data.steps.find( (candidate) => candidate.depositAddress && candidate.requestId, ) if (!step?.depositAddress || !step.requestId) throw new FundingProvider.ProviderPayloadError() const checkEndpoint = step.items?.find((item) => item.check)?.check?.endpoint const providerFee = parsed.data.fees?.relayer?.amount return { address: step.depositAddress, correlation: { ...(checkEndpoint ? { checkEndpoint } : {}), requestId: step.requestId, }, destinationAmount: parsed.data.details.currencyOut.amount, destinationAmountMin: parsed.data.details.currencyOut.minimumAmount, fees: providerFee && BigInt(providerFee) > 0n ? [{ amount: providerFee, side: 'source' }] : [], sampledAt: now().toISOString(), } }, async getQuote({ candidate, route }, signal) { const userAddress = userAddresses[route.source.chain.id] if (userAddress === undefined) return FundingProvider.unavailableResult({ detail: 'relay:quote-v2', message: 'QUOTE_USER_NOT_CONFIGURED', now: now(), source: 'providerQuote', }) const recipientAddress = userAddresses[route.destination.chain.id] if (recipientAddress === undefined) return FundingProvider.unavailableResult({ detail: 'relay:quote-v2', message: 'QUOTE_RECIPIENT_NOT_CONFIGURED', now: now(), source: 'providerQuote', }) const destinationChainId = relayChainId(route.destination.chain) const sourceChainId = relayChainId(route.source.chain) try { const quote = await FundingProvider.requestJson(endpoint, { body: { amount: candidate.sourceAmount.amount, destinationChainId, destinationCurrency: candidate.destinationToken.address, originChainId: sourceChainId, originCurrency: candidate.sourceToken.address, recipient: recipientAddress, tradeType: 'EXACT_INPUT', user: userAddress, }, fetch, headers: options.apiKey ? { 'x-api-key': options.apiKey } : undefined, method: 'POST', signal, }) const parsed = schema.Quote.safeParse(quote) if (!parsed.success) throw new FundingProvider.ProviderPayloadError() const destinationAmount = parsed.data.details?.currencyOut?.amount const status = destinationAmount ? { status: 'available' as const, tier: 'liquid' as const } : { status: 'unknown' as const, tier: 'unknown' as const } return { destinationAmountMin: parsed.data.details?.currencyOut?.minimumAmount, destinationAmount, quality: { estimatedSeconds: parsed.data.details?.timeEstimate, liquiditySource: 'providerQuote', sourceDetail: 'relay:quote-v2', tier: status.tier, }, sampledAt: now().toISOString(), status: status.status, } } catch (cause) { const unavailable = FundingProvider.unavailable(cause, [ 'NO_QUOTES', 'NO_ROUTE', 'UNSUPPORTED_ROUTE', ]) if (!unavailable) throw cause return FundingProvider.unavailableResult({ detail: 'relay:quote-v2', message: unavailable, now: now(), source: 'providerQuote', }) } }, async listDepositAddressRequests({ address, continuation, requestId }, signal) { const requestUrl = requestsEndpoint(options) if (requestId) requestUrl.searchParams.set('id', requestId) else requestUrl.searchParams.set('depositAddress', address) requestUrl.searchParams.set('includeChildRequests', 'true') requestUrl.searchParams.set('limit', '50') requestUrl.searchParams.set('sortBy', 'updatedAt') requestUrl.searchParams.set('sortDirection', 'desc') if (continuation) requestUrl.searchParams.set('continuation', continuation) const response = await FundingProvider.requestJson(requestUrl, { fetch, headers: options.apiKey ? { 'x-api-key': options.apiKey } : undefined, method: 'GET', signal, }) const parsed = parseDepositAddressRequests(response) return { ...(parsed.continuation ? { continuation: parsed.continuation } : {}), requests: parsed.requests.map((request) => { const outputTransactionHashes = request.data?.outTxs?.map((tx) => tx.txHash) ?? [] const refunded = (relayRefundStatuses as readonly string[]).includes(request.status) const correlation = (() => { const { protocol } = request if (!protocol) return undefined const relay = { ...(protocol.deposit?.origin?.depositor ? { depositor: protocol.deposit.origin.depositor } : {}), ...(protocol.deposit?.origin?.depository ? { depository: protocol.deposit.origin.depository } : {}), ...(protocol.orderId ? { orderId: protocol.orderId } : {}), ...(protocol.solver?.address ? { solver: protocol.solver.address } : {}), } if (Object.keys(relay).length === 0) return undefined return schema.RequestCorrelation.parse({ relay }) })() return { createdAt: new Date(request.createdAt).toISOString(), destinationTransactionHashes: refunded ? [] : outputTransactionHashes, id: request.id, ...(correlation ? { providerState: correlation } : {}), ...(refunded && request.refundCurrencyData ? { refundAmountExpected: request.refundCurrencyData.amount } : {}), refundTransactionHashes: refunded ? outputTransactionHashes : [], sourceTransactionHashes: request.depositAddress?.depositTxHash ? [request.depositAddress.depositTxHash] : [], status: request.status, updatedAt: new Date(request.updatedAt).toISOString(), } }), } }, ...(observeDepositAddressRequest ? { observeDepositAddressRequest } : {}), async prepareTransfer({ candidate, method, mode, recipient, sender, slippageBps }, signal) { // Catalog capabilities gate deposit addresses and exact destination // off this adapter; refuse them defensively if one slips through. if (method !== 'transaction' || mode !== 'exactSource') throw new FundingProvider.ProviderConfigurationError() const quote = await (async () => { try { return await FundingProvider.requestJson(endpoint, { body: { amount: candidate.sourceAmount.amount, destinationChainId: Number(FundingChain.eip155Id(candidate.route.destination.chain)), destinationCurrency: candidate.destinationToken.address, originChainId: Number(FundingChain.eip155Id(candidate.route.source.chain)), originCurrency: candidate.sourceToken.address, recipient, ...(slippageBps !== undefined ? { slippageTolerance: String(slippageBps) } : {}), tradeType: 'EXACT_INPUT', user: sender, }, fetch, headers: options.apiKey ? { 'x-api-key': options.apiKey } : undefined, method: 'POST', signal, }) } catch (cause) { if (FundingProvider.unavailable(cause, ['NO_QUOTES', 'NO_ROUTE', 'UNSUPPORTED_ROUTE'])) throw new FundingProvider.ProviderUnavailableError() throw cause } })() const parsed = schema.PreparedQuote.safeParse(quote) if (!parsed.success) throw new FundingProvider.ProviderPayloadError() const { details, fees, steps } = parsed.data // The quoted input must match the request exactly; a drifted amount // would desynchronize the stored terms from the prepared calls. if (details.currencyIn.amount !== candidate.sourceAmount.amount) throw new FundingProvider.ProviderPayloadError() const items = steps .filter((step) => step.kind === 'transaction') .flatMap((step) => step.items ?? []) const requestId = steps.find((step) => step.requestId)?.requestId if (items.length === 0 || !requestId) throw new FundingProvider.ProviderPayloadError() const sourceChainId = Number(FundingChain.eip155Id(candidate.route.source.chain)) for (const item of items) if ( item.data.chainId !== sourceChainId || item.data.from.toLowerCase() !== sender.toLowerCase() ) throw new FundingProvider.ProviderPayloadError() const checkEndpoint = items.find((item) => item.check)?.check?.endpoint // Relay's `relayer` fee is the solver's total source-side charge (gas // plus service); it surfaces as the transfer's provider fee. const providerFee = fees?.relayer?.amount return { action: { calls: items.map((item) => ({ data: item.data.data, to: item.data.to, value: Hex.fromNumber(BigInt(item.data.value ?? '0')), })), type: 'evm:calls', }, correlation: { ...(checkEndpoint ? { checkEndpoint } : {}), requestId }, fees: providerFee && BigInt(providerFee) > 0n ? [{ amount: providerFee, side: 'source' }] : [], destinationAmountMin: details.currencyOut.minimumAmount, destinationAmount: details.currencyOut.amount, quality: { ...(details.timeEstimate !== undefined ? { estimatedSeconds: details.timeEstimate } : {}), liquiditySource: 'providerQuote', sourceDetail: 'relay:quote-v2', tier: 'liquid', }, sampledAt: now().toISOString(), } }, }) } export declare namespace relay { /** Relay provider options. */ type Options = { /** Optional Relay API key for higher rate limits. */ apiKey?: string | undefined /** Override for tests or Relay test environments. */ baseUrl?: string | undefined /** Fetch implementation override for tests. */ fetch?: FundingProvider.Fetch | undefined /** Clock override for deterministic tests. */ now?: (() => Date) | undefined /** EVM chain evidence used to accelerate authenticated provider delivery. */ observation?: Observation | undefined /** Deposit subsidy policy enabled for this integration. */ subsidies?: Subsidy.Policy | undefined /** Optional source and destination quote wallets keyed by CAIP-2 chain id. */ userAddresses?: Readonly>> | string | undefined /** Tempo infrastructure used to receive authenticated Relay webhooks. */ webhook?: Webhook | undefined } /** Chain evidence configuration for Relay delivery observation. */ type Observation = { /** Authoritative funding database or per-request database factory. */ db: Db.Source /** Tempo chain that receives a shared RPC URL override. */ defaultChainId?: Viem.ChainId | undefined /** Shared or per-EVM-chain RPC URL environment value. */ rpcUrlEnv?: Viem.UrlResolver | undefined } /** Authenticated Relay webhook integration attached to a provider instance. */ type Webhook = { /** Authoritative funding database or per-request database factory. */ db: Db.Source /** Enqueues one bounded reconciliation hint. */ dispatch: (message: FundingProvider.Webhook.receive.Dispatchable) => Promise /** Observes one bounded webhook disposition. */ onResult?: ((result: WebhookResult) => void) | undefined } /** Bounded operational result emitted while receiving a Relay webhook. */ type WebhookResult = | 'authentication_failed' | 'enqueue_failed' | 'ignored' | 'observation_failed' | 'payload_invalid' | 'queued' | 'unknown_address' } function parseUserAddresses(value: relay.Options['userAddresses']) { if (!value) return {} const addresses = typeof value === 'string' ? (() => { try { return JSON.parse(value) as unknown } catch { throw new FundingProvider.ProviderConfigurationError() } })() : value if (!addresses || typeof addresses !== 'object' || Array.isArray(addresses)) throw new FundingProvider.ProviderConfigurationError() return addresses as Readonly>> } function invalidUserAddress(chainId: string, address: unknown) { if (typeof address !== 'string') return true if (/^eip155:\d+$/.test(chainId)) return !Address.validate(address, { strict: false }) if (/^solana:[1-9A-HJ-NP-Za-km-z]+$/.test(chainId)) { try { return Base58.toBytes(address).length !== 32 } catch { return true } } if (!/^tron:0x[0-9a-fA-F]+$/.test(chainId)) return true try { const bytes = Base58.toBytes(address) if (bytes.length !== 25 || bytes[0] !== 0x41) return true const payload = bytes.slice(0, 21) const checksum = Hash.sha256(Hash.sha256(payload), { as: 'Bytes' }).slice(0, 4) return !Bytes.isEqual(bytes.slice(21), checksum) } catch { return true } } type DepositAddressObserverOptions = relay.Observation & { apiKey?: string | undefined baseUrl?: string | undefined fetch: FundingProvider.Fetch now: () => Date } type RelayChain = z.output['chains'][number] type RelayDeliveryObservation = z.output['relay'] function createDepositAddressObserver( options: DepositAddressObserverOptions, ): NonNullable { let chainsExpiresAt = 0 let chainsPromise: Promise | undefined let urlsExpiresAt = 0 let urlsPromise: Promise> | undefined const getChains = (signal: AbortSignal) => { if (chainsPromise && options.now().getTime() < chainsExpiresAt) return chainsPromise chainsExpiresAt = options.now().getTime() + relayChainsCacheTtlMs chainsPromise = FundingProvider.requestJson(chainsEndpoint(options), { fetch: options.fetch, headers: options.apiKey ? { 'x-api-key': options.apiKey } : undefined, signal, }) .then((value) => schema.Chains.parse(value).chains) .catch((cause) => { chainsExpiresAt = 0 chainsPromise = undefined throw cause }) return chainsPromise } const getUrls = () => { if (urlsPromise && options.now().getTime() < urlsExpiresAt) return urlsPromise urlsExpiresAt = options.now().getTime() + relayChainsCacheTtlMs urlsPromise = FundingCatalog.read(Db.get(options.db)) .then( (catalog) => new Map( [...catalog.chainsByKey.values()].map( (chain) => [ chain.id, Evidence.resolveRpcUrls({ chain, defaultChainId: options.defaultChainId ?? Viem.defaultChainId, rpcUrlEnv: options.rpcUrlEnv, }), ] as const, ), ), ) .catch((cause) => { urlsExpiresAt = 0 urlsPromise = undefined throw cause }) return urlsPromise } return async (parameters, signal) => { if ( parameters.request && !(deliveryObservationStatuses as readonly string[]).includes(parameters.request.status) ) return { type: 'unsupported' } if (parameters.sourceChain.kind !== 'evm' || parameters.destinationChain.kind !== 'evm') return { type: 'unsupported' } if ( !Address.validate(parameters.address, { strict: false }) || !Address.validate(parameters.destinationToken.address, { strict: false }) || !Address.validate(parameters.recipient, { strict: false }) || !Address.validate(parameters.refundAddress, { strict: false }) || !Address.validate(parameters.sourceToken.address, { strict: false }) || !Hash.validate(parameters.source.transactionHash) ) return { type: 'unsupported' } const [chains, urls] = await Promise.all([getChains(signal), getUrls()]) const sourceChain = relayEvmChain(chains, parameters.sourceChain) const destinationChain = relayEvmChain(chains, parameters.destinationChain) const sourceUrls = urls.get(parameters.sourceChain.id) const destinationUrls = urls.get(parameters.destinationChain.id) const depository = sourceChain?.protocol?.v2?.depository const solvers = destinationChain?.solverAddresses if ( !depository || !solvers || solvers.length === 0 || !sourceUrls?.length || !destinationUrls?.length ) return { type: 'unsupported' } const parsedCorrelation = schema.RequestCorrelation.safeParse(parameters.request?.providerState) const correlation = parsedCorrelation.success ? parsedCorrelation.data.relay : undefined if ( (correlation?.depository && !Address.isEqual( correlation.depository as Address.Address, depository as Address.Address, )) || (correlation?.solver && !solvers.some((solver) => Address.isEqual(solver as Address.Address, correlation.solver as Address.Address), )) ) return { type: 'unsupported' } const depositor = (correlation?.depositor ?? parameters.refundAddress) as Address.Address const stored = schema.DeliveryObservation.safeParse(parameters.providerState) const sourceObservation = await (async () => { if (stored.success) return stored.data.relay const sourceClient = Viem.createEvmClient({ fetch: options.fetch, urls: sourceUrls }) const sourceReceipt = await getTransactionReceipt(sourceClient, { hash: parameters.source.transactionHash as Hex.Hex, }) if (sourceReceipt.status !== 'success' || sourceReceipt.blockNumber === null) return undefined const sourceHead = await getBlockNumber(sourceClient, { cacheTime: 0 }) const sweepLogs = await getLogs(sourceClient, { address: depository as Address.Address, event: relayErc20DepositEvent, fromBlock: sourceReceipt.blockNumber, toBlock: minimum(sourceHead, sourceReceipt.blockNumber + sourceSweepForwardBlocks), }) const observations: RelayDeliveryObservation[] = [] for (const log of sweepLogs) { if (!log.transactionHash) continue const event = decodeEvent(relayErc20DepositEvent, log) if ( !event || !Address.isEqual(event.from, depositor) || !Address.isEqual(event.token, parameters.sourceToken.address as Address.Address) || event.amount !== BigInt(parameters.source.amount) || (correlation?.orderId && !Hex.isEqual(event.id, correlation.orderId as Hex.Hex)) ) continue const transaction = await getTransaction(sourceClient, { hash: log.transactionHash }) const call = decodeRelayErc20Deposit(transaction.input) if ( !call || !Address.isEqual(transaction.from, parameters.address as Address.Address) || !transaction.to || !Address.isEqual(transaction.to, depository as Address.Address) || !Address.isEqual(call.depositor, event.from) || !Address.isEqual(call.token, event.token) || (call.amount !== undefined && call.amount !== event.amount) || !Hex.isEqual(call.id, event.id) ) continue observations.push({ orderId: event.id, sourceDepository: depository, sourceObservedAt: options.now().toISOString(), sourceSweepTransactionHash: log.transactionHash, }) } return observations.length === 1 ? observations[0] : undefined })() if (!sourceObservation) return { type: 'pending' } const destinationClient = Viem.createEvmClient({ fetch: options.fetch, urls: destinationUrls }) const destinationHead = await getBlockNumber(destinationClient, { cacheTime: 0 }) const destinationLogs = await getLogs(destinationClient, { address: parameters.destinationToken.address as Address.Address, args: { to: parameters.recipient as Address.Address }, event: transferEvent, fromBlock: destinationHead > deliveryBackfillBlocks ? destinationHead - deliveryBackfillBlocks : 0n, toBlock: destinationHead, }) const solverAddresses = (correlation?.solver ? [correlation.solver] : solvers).map( (address) => address as Address.Address, ) const correlatedTransactionHashes: Hex.Hex[] = [] const uncorrelatedTransactionHashes: Hex.Hex[] = [] for (const log of destinationLogs) { if ( !log.transactionHash || correlatedTransactionHashes.includes(log.transactionHash) || uncorrelatedTransactionHashes.includes(log.transactionHash) ) continue const event = decodeEvent(transferEvent, log) if ( !event || event.value === 0n || !Address.isEqual(event.to, parameters.recipient as Address.Address) || !solverAddresses.some((address) => Address.isEqual(address, event.from)) ) continue const transaction = await getTransaction(destinationClient, { hash: log.transactionHash }) const call = decodeRelayTransfer(transaction.input) if ( !call || !solverAddresses.some((address) => Address.isEqual(address, transaction.from)) || !Address.isEqual(event.from, transaction.from) || !transaction.to || !Address.isEqual(transaction.to, parameters.destinationToken.address as Address.Address) || !Address.isEqual(call.recipient, event.to) || call.amount !== event.value || (call.orderId && !Hex.isEqual(call.orderId, sourceObservation.orderId as Hex.Hex)) ) continue if (call.orderId) correlatedTransactionHashes.push(log.transactionHash) else uncorrelatedTransactionHashes.push(log.transactionHash) } const destinationTransactionHashes = (() => { if (correlatedTransactionHashes.length > 0) return correlatedTransactionHashes // Provider-independent completion requires the Relay order id to bind both chain legs. if (!parameters.request) return [] if (uncorrelatedTransactionHashes.length === 1) return uncorrelatedTransactionHashes return [] })() const providerState = { relay: sourceObservation } if (destinationTransactionHashes.length === 0) return { providerState, providerTransactionHashes: [sourceObservation.sourceSweepTransactionHash], type: 'pending', } return { destinationTransactionHashes, providerState: { relay: { ...sourceObservation, destinationObservedAt: options.now().toISOString(), }, }, providerTransactionHashes: [ sourceObservation.sourceSweepTransactionHash, ...destinationTransactionHashes, ], type: 'verified', } } } function chainsEndpoint(options: Pick): URL { const endpoint = new URL(options.baseUrl ?? defaultBaseUrl) endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, '')}/chains` return endpoint } function decodeRelayErc20Deposit(data: Hex.Hex) { const fullAllowanceLength = 2 + 2 * (4 + 32 * 3) const withAmountLength = 2 + 2 * (4 + 32 * 4) try { if (data.length === withAmountLength) { const [depositor, token, amount, id] = AbiFunction.decodeData(depositErc20WithAmount, data) return { amount, depositor, id, token } } if (data.length === fullAllowanceLength) { const [depositor, token, id] = AbiFunction.decodeData(depositErc20FullAllowance, data) return { depositor, id, token } } return undefined } catch { return undefined } } function decodeRelayTransfer(data: Hex.Hex) { const encodedLength = 2 + 2 * (4 + 32 * 2) if (data.length !== encodedLength && data.length !== encodedLength + 64) return undefined try { const [recipient, amount] = AbiFunction.decodeData( relayTransfer, data.slice(0, encodedLength) as Hex.Hex, ) return { amount, ...(data.length > encodedLength ? { orderId: `0x${data.slice(encodedLength)}` as Hex.Hex } : {}), recipient, } } catch { return undefined } } function decodeEvent( abiEvent: event, log: { data: Hex.Hex; topics: readonly Hex.Hex[] }, ): AbiEvent.decode.ReturnType | undefined { try { return AbiEvent.decode(abiEvent, log) } catch { return undefined } } function minimum(left: bigint, right: bigint): bigint { return left < right ? left : right } function relayEvmChain( chains: readonly RelayChain[], chain: FundingProvider.ChainRef, ): RelayChain | undefined { if (chain.kind !== 'evm') return undefined const match = /^eip155:(\d+)$/.exec(chain.id) if (!match?.[1]) return undefined return chains.find((candidate) => candidate.id === Number(match[1])) } type VerifyWebhookOptions = { body: string secret: string signature: string timestamp: string } function verifyWebhook(options: VerifyWebhookOptions): FundingProvider.Webhook.verify.ReturnType { const timestamp = Number(options.timestamp) if (!Number.isSafeInteger(timestamp)) throw new WebhookSignatureError() if (Math.abs(Date.now() - timestamp) > webhookSignatureToleranceMs) throw new WebhookSignatureError() if (!/^[0-9a-fA-F]{64}$/.test(options.signature)) throw new WebhookSignatureError() const expected = Hash.hmac256( Hex.fromString(options.secret), Hex.fromString(`${options.timestamp}.${options.body}`), ).slice(2) if (!timingSafeEqual(expected, options.signature)) throw new WebhookSignatureError() const payload = (() => { try { return JSON.parse(options.body) as unknown } catch { throw new WebhookPayloadError() } })() const parsed = schema.WebhookPayload.safeParse(payload) if (!parsed.success) throw new WebhookPayloadError() return { address: parsed.data.data.depositAddress?.address, providerStatus: webhookStatus(parsed.data.data.status), providerUpdatedAt: webhookTimestamp(parsed.data.data.updatedAt), requestId: parsed.data.data.requestId, sentAt: webhookTimestamp(timestamp), } } function webhookOptions( input: FundingProvider.Webhook.verify.Parameters, secret: string, ): VerifyWebhookOptions { const signature = input.headers.get('x-signature-sha256') const timestamp = input.headers.get('x-signature-timestamp') if (!signature || !timestamp) throw new WebhookSignatureError() return { body: input.body, secret, signature, timestamp } } function quoteEndpoint(options: relay.Options): URL { const endpoint = new URL(options.baseUrl ?? defaultBaseUrl) endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, '')}/quote/v2` return endpoint } function requestsEndpoint(options: relay.Options): URL { const endpoint = new URL(options.baseUrl ?? defaultBaseUrl) endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, '')}/requests/v3` return endpoint } function timingSafeEqual(left: string, right: string): boolean { if (left.length !== right.length) return false let mismatch = 0 for (let index = 0; index < left.length; index++) mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index) return mismatch === 0 } function webhookStatus(status: string): string { return (webhookStatuses as readonly string[]).includes(status) ? status : 'unknown' } function webhookTimestamp(value: number): string { try { return new Date(value).toISOString() } catch { throw new WebhookPayloadError() } } function relayChainId(chain: FundingProvider.Chain): number { if (chain.id.startsWith('eip155:')) return Number(FundingChain.eip155Id(chain)) // Relay uses a numeric chain ID for Solana instead of its CAIP-2 reference. if (chain.id === 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') return 792_703_809 if (chain.id.startsWith('tron:')) return Number(chain.id.slice('tron:'.length)) throw new FundingProvider.ProviderConfigurationError() } /** Relay webhook signature is missing, stale, or invalid. */ export class WebhookSignatureError extends FundingProvider.WebhookAuthenticationError { override name = 'Funding.Provider.Relay.WebhookSignatureError' } /** Relay webhook body does not match the expected status event shape. */ export class WebhookPayloadError extends FundingProvider.WebhookPayloadError { override name = 'Funding.Provider.Relay.WebhookPayloadError' }