/** @module-tag localnet */ import { AbiEvent, AbiParameters, type Hex } from 'ox' import * as TestApp from '../../../../test/App.js' import * as Runtime from '../../../../test/runtime.js' import * as Auth from '../../../internal/Auth.js' import * as Schema from '../../../internal/Schema.js' import type * as Viem from '../../../internal/Viem.js' import * as Zones from './zones.js' const runtime = Runtime.get() const portal = Schema.Address.parse('0x59831A17340EE14FE136d751EfbeA8b630470fD2') const senderTag = Schema.Hash.parse( '0x61fc42a734cb44acef497ea0bed612c691195623244136e0739e1929569e2487', ) const otherSenderTag = Schema.Hash.parse( '0x71fc42a734cb44acef497ea0bed612c691195623244136e0739e1929569e2487', ) const completedSenderTag = Schema.Hash.parse( '0x479ebbe3732639186397e0b0ee1e996a3e8a94d2ee8cd9fc833c02299eca28a2', ) const failedSenderTag = Schema.Hash.parse( '0x500570222c9b2c198638f14f66cea1d37de5a1b86b0880151d42cd2b3f4d5804', ) const transactionHash = Schema.Hash.parse( '0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665', ) const withdrawalProcessedEvent = AbiEvent.from( 'event WithdrawalProcessed(address indexed to, bytes32 indexed senderTag, address token, uint128 amount, bool callbackSuccess)', ) const withdrawalProcessedData = AbiParameters.from(['address', 'uint128', 'bool']) const recipient = Schema.Address.parse('0xbe058e1c4df8a4366a387bf595b284246a93039e') const token = Schema.Address.parse('0x20c0000000000000000000000000000000000000') type OpenApiErrorSchema = { $ref?: string properties?: { error?: { properties?: { code?: { enum?: readonly string[] } } } } } type OpenApiDocument = { components?: { schemas?: Record } paths: { '/v1/zones/withdrawals/{senderTag}'?: { get?: { operationId?: string parameters?: readonly { name: string required?: boolean schema?: { examples?: readonly unknown[] } }[] responses?: { 200?: { content?: { 'application/json'?: { examples?: Record schema?: { properties?: { data?: { items?: { properties?: Record } } } } } } } 400?: { content?: { 'application/json'?: { schema?: OpenApiErrorSchema } } } } } } } } const zoneReader = { id: 'key_zone_withdrawal', orgId: 'org_test', scopes: ['data:read', `zone:${runtime.zone.chainId}:read`], token: 'secret_zone_withdrawal', } satisfies TestApp.kvStore.Key function client(options: { keys?: readonly TestApp.kvStore.Key[] | undefined } = {}) { return TestApp.client({ auth: { keys: options.keys ?? [zoneReader] }, zones: [ TestApp.zone({ chainId: runtime.zone.chainId, rpcUrl: runtime.zone.internalRpcUrl, sourceChainId: runtime.chainId as Viem.ChainId, }), ], }) } function auth(key: TestApp.kvStore.Key = zoneReader) { return { headers: { authorization: `Bearer ${key.token}` } } } function row( options: { blockNumber?: number | undefined callbackSuccess?: boolean | undefined logIndex?: number | undefined senderTag?: Hex.Hex | undefined } = {}, ): Record { const tag = options.senderTag ?? senderTag const { topics } = AbiEvent.encode(withdrawalProcessedEvent, { senderTag: tag, to: recipient, }) const [selector, topic1, topic2] = topics if (typeof selector !== 'string' || typeof topic1 !== 'string' || typeof topic2 !== 'string') throw new Error('Unable to encode WithdrawalProcessed fixture topics') return { address: portal, block_num: options.blockNumber ?? 123, data: AbiParameters.encode(withdrawalProcessedData, [ token, 1_000_000n, options.callbackSuccess ?? true, ]), log_idx: options.logIndex ?? 4, selector, topic1, topic2, tx_hash: transactionHash, } } describe('resolveWithdrawalProcessed', () => { test('returns failed when the callback failed', () => { expect( Zones.resolveWithdrawalProcessed({ portal, rows: [row({ callbackSuccess: false })], senderTag, tempoBlockNumber: 123n, }), ).toMatchInlineSnapshot(` { "data": [ { "blockNumber": 123, "id": "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-4", "logIndex": 4, "status": "failed", "transactionHash": "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665", "zoneStatus": "completed", }, ], } `) }) test('returns completed when the callback succeeded', () => { expect( Zones.resolveWithdrawalProcessed({ portal, rows: [row()], senderTag, tempoBlockNumber: 122n, }), ).toMatchInlineSnapshot(` { "data": [ { "blockNumber": 123, "id": "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-4", "logIndex": 4, "status": "completed", "transactionHash": "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665", "zoneStatus": "pending", }, ], } `) }) test('rejects malformed indexed events', () => { expect(() => Zones.resolveWithdrawalProcessed({ portal, rows: [{ ...row(), data: '0x00' }], senderTag, tempoBlockNumber: 123n, }), ).toThrowErrorMatchingInlineSnapshot( `[Error: Upstream returned a malformed WithdrawalProcessed event]`, ) }) test('returns every distinct event sharing one sender tag', () => { expect( Zones.resolveWithdrawalProcessed({ portal, rows: [row({ callbackSuccess: false }), row({ blockNumber: 124, logIndex: 5 })], senderTag, tempoBlockNumber: 123n, }), ).toMatchInlineSnapshot(` { "data": [ { "blockNumber": 123, "id": "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-4", "logIndex": 4, "status": "failed", "transactionHash": "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665", "zoneStatus": "completed", }, { "blockNumber": 124, "id": "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-5", "logIndex": 5, "status": "completed", "transactionHash": "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665", "zoneStatus": "pending", }, ], } `) }) test('rejects an event carrying a different sender tag', () => { expect(() => Zones.resolveWithdrawalProcessed({ portal, rows: [row({ senderTag: otherSenderTag })], senderTag, tempoBlockNumber: 123n, }), ).toThrowErrorMatchingInlineSnapshot( `[Error: Upstream returned a malformed WithdrawalProcessed event]`, ) }) }) describe('GET /v1/zones/withdrawals/:senderTag', () => { test.runIf(runtime.mode === 'testnet')( 'returns terminal L1 outcomes before Zone catch-up', async () => { for (const [tag, expected] of [ [ completedSenderTag, { blockNumber: 27_801_150, id: '0x7daa6cf6b493d2455232a536f8fb121e2620361eb71341f390a9e07d90142fc6-114', logIndex: 114, status: 'completed', transactionHash: '0x7daa6cf6b493d2455232a536f8fb121e2620361eb71341f390a9e07d90142fc6', zoneStatus: 'completed', }, ], [ failedSenderTag, { blockNumber: 27_801_149, id: '0xde78af3b44ec95385efcd3b045bb9a641194a0ba5d9470dc1ca9dec4037e9b80-104', logIndex: 104, status: 'failed', transactionHash: '0xde78af3b44ec95385efcd3b045bb9a641194a0ba5d9470dc1ca9dec4037e9b80', zoneStatus: 'completed', }, ], ] as const) { const response = await client().v1.zones.withdrawals[':senderTag'].$get( { param: { senderTag: tag }, query: { chainId: String(runtime.zone.chainId) }, }, auth(), ) const body = await TestApp.json(response, Zones.schema.listZoneWithdrawals.Response) expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('server-timing')).toContain('zone_status;dur=') expect(response.headers.get('server-timing')).toContain('zone_withdrawal;dur=') expect(body).toEqual({ data: [expected] }) } }, ) test.runIf(runtime.mode === 'localnet')( 'returns an empty list for sender tags absent from the parent Tempo indexer', async () => { const response = await client().v1.zones.withdrawals[':senderTag'].$get( { param: { senderTag }, query: { chainId: String(runtime.zone.chainId) }, }, auth(), ) const body = await TestApp.json(response, Zones.schema.listZoneWithdrawals.Response) expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('server-timing')).not.toContain('zone_status;dur=') expect(response.headers.get('server-timing')).toContain('zone_withdrawal;dur=') expect(body).toEqual({ data: [] }) }, ) test('requires the matching Zone read scope', async () => { const otherReader = { id: 'key_other_zone_withdrawal', orgId: 'org_test', scopes: ['data:read', `zone:${runtime.zone.chainId + 1}:read`], token: 'secret_other_zone_withdrawal', } satisfies TestApp.kvStore.Key const response = await client({ keys: [otherReader] }).v1.zones.withdrawals[':senderTag'].$get( { param: { senderTag }, query: { chainId: String(runtime.zone.chainId) }, }, auth(otherReader), ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(403) expect(body.error.code).toBe('api_key_forbidden') expect(body.error.message).toBe( `Chain ID ${runtime.zone.chainId} is a Zone. Use an API key granting \`zone:${runtime.zone.chainId}:read\` or \`zone:${runtime.zone.chainId}:write\`.`, ) }) test('requires an authenticated API key', async () => { const response = await client().v1.zones.withdrawals[':senderTag'].$get( { param: { senderTag }, query: { chainId: String(runtime.zone.chainId) }, }, { headers: { authorization: 'Bearer invalid' } }, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(401) expect(body.error.code).toBe('api_key_invalid') }) test('rejects invalid sender tags and chain ids', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [ TestApp.zone({ chainId: runtime.zone.chainId, rpcUrl: runtime.zone.internalRpcUrl, sourceChainId: runtime.chainId as Viem.ChainId, }), ], }) const invalidSenderTag = await app.request( `/v1/zones/withdrawals/not-a-tag?chainId=${runtime.zone.chainId}`, auth(), ) const invalidSenderTagBody = await TestApp.json(invalidSenderTag, Schema.ErrorResponse) const invalidChainId = await app.request( `/v1/zones/withdrawals/${senderTag}?chainId=nope`, auth(), ) const invalidChainIdBody = await TestApp.json(invalidChainId, Schema.ErrorResponse) const missingChainId = await app.request(`/v1/zones/withdrawals/${senderTag}`, auth()) const missingChainIdBody = await TestApp.json(missingChainId, Schema.ErrorResponse) expect(invalidSenderTag.status).toBe(400) expect(invalidSenderTagBody.error.code).toBe('sender_tag_invalid') expect(invalidChainId.status).toBe(400) expect(invalidChainIdBody.error.code).toBe('chain_id_invalid') expect(missingChainId.status).toBe(400) expect(missingChainIdBody.error.code).toBe('query_invalid') }) test('requires API-key access without public or MPP lanes', () => { expect(Auth.describeAccess(Zones.zones())['listZoneWithdrawals']).toMatchInlineSnapshot(` { "apiKey": true, "mpp": false, "public": false, "scopes": [ "data:read", ], "session": false, } `) }) test('documents parameters, examples, and response variants', async () => { const app = TestApp.create({ auth: false }) const document = (await (await app.request('/openapi.json')).json()) as OpenApiDocument const operation = document.paths['/v1/zones/withdrawals/{senderTag}']?.get if (!operation) throw new Error('Missing Zone withdrawal OpenAPI operation') expect(operation.operationId).toBe('listZoneWithdrawals') expect( operation.parameters?.map((parameter) => ({ examples: parameter.schema?.examples, name: parameter.name, required: parameter.required, })), ).toMatchInlineSnapshot(` [ { "examples": [ "0x61fc42a734cb44acef497ea0bed612c691195623244136e0739e1929569e2487", ], "name": "senderTag", "required": true, }, { "examples": [ 1424310003, ], "name": "chainId", "required": true, }, ] `) const response = operation.responses?.[200]?.content?.['application/json'] const errorSchema = operation.responses?.[400]?.content?.['application/json']?.schema const errorSchemaName = errorSchema?.$ref?.split('/').at(-1) const errorSchema_resolved = errorSchemaName ? document.components?.schemas?.[errorSchemaName] : errorSchema expect(Object.keys(response?.examples ?? {}).sort()).toEqual(['empty', 'processed']) expect(errorSchema_resolved?.properties?.error?.properties?.code?.enum).toEqual([ 'api_key_malformed', 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'sender_tag_invalid', ]) expect( Object.fromEntries( Object.entries(response?.schema?.properties?.data?.items?.properties ?? {}).map( ([name, property]) => [name, property.examples], ), ), ).toMatchInlineSnapshot(` { "blockNumber": [ 123, ], "id": [ "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-4", ], "logIndex": [ 4, ], "status": [ "failed", "completed", ], "transactionHash": [ "0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665", ], "zoneStatus": [ "pending", "completed", ], } `) }) })