import { Hono } from 'hono' import { AbiEvent, AbiFunction, type Hex } from 'ox' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Timing from '../../../internal/Timing.js' import * as Value from '../../../internal/Value.js' const exampleSenderTag = '0x61fc42a734cb44acef497ea0bed612c691195623244136e0739e1929569e2487' const exampleTransactionHash = '0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665' const exampleWithdrawalId = `${exampleTransactionHash}-4` const withdrawalProcessedEvent = AbiEvent.from( 'event WithdrawalProcessed(address indexed to, bytes32 indexed senderTag, address token, uint128 amount, bool callbackSuccess)', ) const withdrawalProcessedSelector = AbiEvent.getSelector(withdrawalProcessedEvent) const lastSyncedTempoBlockNumberFunction = AbiFunction.from( 'function lastSyncedTempoBlockNumber() view returns (uint64)', ) /** Zod schemas owned by Zone handlers. */ export namespace schema { /** Schemas for the listZoneWithdrawals operation. */ export namespace listZoneWithdrawals { /** Path parameters for a Zone withdrawal list request. */ export const Params = z .object({ senderTag: Schema.hash(exampleSenderTag).check( z.describe( 'The 32-byte correlation tag shared by withdrawals from one sender in one Zone transaction.', ), ), }) .check(z.describe('Path parameters used to correlate Zone withdrawals.')) /** Query parameters for a Zone withdrawal list request. */ export const Query = z .strictObject({ chainId: Schema.ChainId.check( z.describe('The chain id of the Zone where the withdrawal originated.'), z.meta({ examples: [1_424_310_003] }), ), }) .check(z.describe('Query parameters used to select the originating Zone.')) /** Indexed Zone withdrawal after parent-chain processing. */ export const Withdrawal = z .object({ blockNumber: z .number() .check( z.int(), z.nonnegative(), z.describe('Parent-chain block containing the withdrawal processing event.'), z.meta({ examples: [123] }), ), id: z .string() .check( z.describe( 'Stable withdrawal id built from the parent transaction hash and log index (`${transactionHash}-${logIndex}`).', ), z.meta({ examples: [exampleWithdrawalId] }), ), logIndex: z .number() .check( z.int(), z.nonnegative(), z.describe('Event position within the parent-chain block.'), z.meta({ examples: [4] }), ), status: z .enum(['failed', 'completed']) .check( z.describe('Whether parent-chain delivery or processing failed or completed.'), z.meta({ examples: ['failed', 'completed'] }), ), transactionHash: Schema.hash(exampleTransactionHash).check( z.describe('Parent-chain transaction that processed the withdrawal.'), ), zoneStatus: z .enum(['pending', 'completed']) .check( z.describe( 'Completed once the Zone has proven it imported the parent-chain `blockNumber` containing this withdrawal.', ), z.meta({ examples: ['pending', 'completed'] }), ), }) .check(z.describe('A Zone withdrawal processed on its parent chain.')) /** Non-paginated list of indexed Zone withdrawal outcomes. */ export const Response = Schema.describe( z.object({ data: z .array(Withdrawal) .check( z.describe( 'Indexed parent-chain withdrawal outcomes sharing the sender tag. The list may grow while matching withdrawals are processed.', ), ), }), 'A non-paginated list of parent-chain withdrawal outcomes correlated by sender tag.', ) } } /** Creates Zone handlers. */ export function zones() { return new Hono().get( '/v1/zones/withdrawals/:senderTag', Auth.policy({ apiKey: { scopes: ['data:read'] } }), OpenApi.validate('param', schema.listZoneWithdrawals.Params, { code: 'sender_tag_invalid', message: 'Invalid sender tag', }), OpenApi.validate('query', schema.listZoneWithdrawals.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Lists L1 withdrawal outcomes by Zone sender tag. L1 success requires every `status` completed; Zone success also requires every `zoneStatus` completed. Empty lists remain pending.', operationId: 'listZoneWithdrawals', responses: OpenApi.responses({ errors: { 400: { codes: [ 'api_key_malformed', 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'sender_tag_invalid', ], }, 403: 'The API key does not grant read access to the originating Zone.', 502: 'The parent-chain indexer or RPC returned unavailable or malformed data.', }, success: { description: 'Indexed parent-chain withdrawal outcomes correlated by sender tag.', examples: { empty: { summary: 'Not indexed', value: { data: [] }, }, processed: { summary: 'Processed', value: { data: [ { blockNumber: 123, id: exampleWithdrawalId, logIndex: 4, status: 'completed', transactionHash: exampleTransactionHash, zoneStatus: 'pending', }, ], }, }, }, schema: schema.listZoneWithdrawals.Response, }, }), summary: 'List Zone withdrawals', tags: ['Zones'], }), Cache.response({ cacheControl: Cache.policies.noStore, name: 'tempo-api:zone-withdrawals:v1', key: (c) => { const principal = Auth.getPrincipal(c) const url = new URL(Cache.urlKey(c, schema.listZoneWithdrawals.Query)) url.searchParams.set( 'principal', `${principal?.type ?? 'unknown'}:${principal?.id ?? 'unknown'}`, ) return url.toString() }, }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'sender_tag_invalid', message: 'Invalid sender tag', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'chain_id_invalid', message: 'Invalid chain id', }) const { senderTag } = c.req.valid('param') const { chainId } = c.req.valid('query') const zone = c.get('zones').get(chainId) if (!zone) return Response.unsupportedChainId(c, chainId, c.get('zones').keys()) try { const parentChainId = Schema.ChainId.parse(zone.sourceId) const portalContract = zone.contracts?.['portal'] const portal = Schema.Address.parse( portalContract && 'address' in portalContract ? portalContract.address : portalContract?.[parentChainId]?.address, ) const tidx = c.get('getTidx')(parentChainId) const result = await Timing.time(c, 'zone_withdrawal', () => tidx.fetch({ chainId: parentChainId, engine: 'clickhouse', // Dynamic raw-log query, constrained to schema-validated hex literals. // FINAL collapses replayed physical rows before returning distinct events. query: ` SELECT block_num, log_idx, tx_hash, address, selector, topic1, topic2, data FROM logs FINAL WHERE address = '${portal}' AND selector = '${withdrawalProcessedSelector}' AND topic2 = '${senderTag}' ORDER BY block_num ASC, log_idx ASC ` as string, }), ) if (result.rows.length === 0) return c.json(Response.validated(schema.listZoneWithdrawals.Response, { data: [] }), 200) // TODO: Use `zone_getZoneInfo` once Zone RPC versions stabilize. const tempoBlockNumber = await Timing.time(c, 'zone_status', () => c .get('getClient')(parentChainId) .readContract({ abi: [lastSyncedTempoBlockNumberFunction], address: portal, functionName: 'lastSyncedTempoBlockNumber', }), ) const data = resolveWithdrawalProcessed({ portal, rows: result.rows, senderTag, tempoBlockNumber, }) return c.json(Response.validated(schema.listZoneWithdrawals.Response, data), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) } /** * Resolves indexed `WithdrawalProcessed` rows into public withdrawal outcomes * and rejects malformed upstream data. */ export function resolveWithdrawalProcessed( options: resolveWithdrawalProcessed.Options, ): z.output { const { portal, rows, senderTag, tempoBlockNumber } = options return { data: rows.map((row) => { const address = Schema.Address.safeParse(row['address']) const blockNumber = Value.toNumber(row['block_num']) const data = Schema.Hex.safeParse(row['data']) const logIndex = Value.toNumber(row['log_idx']) const selector = Schema.Hash.safeParse(row['selector']) const topic1 = Schema.Hash.safeParse(row['topic1']) const topic2 = Schema.Hash.safeParse(row['topic2']) const transactionHash = Schema.Hash.safeParse(row['tx_hash']) if ( !address.success || address.data !== portal || blockNumber === undefined || !Number.isSafeInteger(blockNumber) || blockNumber < 0 || !data.success || logIndex === undefined || !Number.isSafeInteger(logIndex) || logIndex < 0 || !selector.success || selector.data !== withdrawalProcessedSelector || !topic1.success || !topic2.success || topic2.data !== senderTag || !transactionHash.success ) throw new Error('Upstream returned a malformed WithdrawalProcessed event') const decoded = (() => { try { return AbiEvent.decode(withdrawalProcessedEvent, { data: data.data, topics: [selector.data, topic1.data, topic2.data], }) } catch { return undefined } })() if ( !decoded || !Schema.Address.safeParse(decoded.to).success || decoded.senderTag.toLowerCase() !== senderTag || !Schema.Address.safeParse(decoded.token).success || typeof decoded.amount !== 'bigint' || typeof decoded.callbackSuccess !== 'boolean' ) throw new Error('Upstream returned a malformed WithdrawalProcessed event') return { blockNumber, id: `${transactionHash.data}-${logIndex}`, logIndex, status: decoded.callbackSuccess ? 'completed' : 'failed', transactionHash: transactionHash.data, zoneStatus: tempoBlockNumber >= BigInt(blockNumber) ? 'completed' : 'pending', } }), } } export declare namespace resolveWithdrawalProcessed { /** Inputs for resolving indexed Zone withdrawal processing rows. */ type Options = { /** Configured ZonePortal address on the parent Tempo chain. */ portal: z.output /** Matching raw TIDX log rows ordered by parent-chain position. */ rows: readonly Record[] /** Sender tag supplied by the caller. */ senderTag: Hex.Hex /** Latest parent-chain block the originating Zone proved it imported. */ tempoBlockNumber: bigint } }