import { Value } from 'ox' import { decodeFunctionData, erc20Abi, maxUint256 } from 'viem' import { base } from 'viem/chains' import { tempoMainnet } from 'viem/tempo/chains' import * as Anvil from '../../../../test/Anvil.js' import * as TestApp from '../../../../test/App.js' import * as TestFunding from '../../../../test/Funding.js' import * as FundingIdempotencyTable from '../../../db/tables/fundingIdempotency.js' import * as Cache from '../../../internal/Cache.js' import * as FundingCatalog from '../../../internal/funding/Catalog.js' import * as FundingChain from '../../../internal/funding/Chain.js' import * as FundingProvider from '../../../internal/funding/Provider.js' import * as FundingTransfer from '../../../internal/funding/Transfer.js' import * as FundingTransferReconciliation from '../../../internal/funding/TransferReconciliation.js' import * as Idempotency from '../../../internal/Idempotency.js' import * as Relay from '../../../internal/funding/providers/relay.js' import * as Stargate from '../../../internal/funding/providers/stargate.js' import * as Webhooks from '../../../internal/Webhooks.js' import * as Funding from './transfers.js' const db = TestApp.database() const destinationTransactionHash = '0x3dfaba0a78b8f4bc09e55e05211c8120729e7b6879efe32613883befbca666da' /** Providers exercised against executable calls on a forked source chain. */ const executableProviders = [ { create: (_sender: string) => Relay.relay(process.env.RELAY_API_KEY ? { apiKey: process.env.RELAY_API_KEY } : {}), destinationToken: 'pathusd', id: 'relay', }, { create: (_sender: string) => Stargate.stargate(), destinationToken: 'usdce', id: 'stargate', }, ] as const const stargateConfiguration = TestFunding.snapshot.routesByProvider .get('stargate')! .find((candidate) => candidate.route.source.chain.id === 'eip155:8453')!.configuration! beforeAll(() => TestFunding.publish(db)) function create(options: TestApp.create.Options = {}) { return TestApp.create({ ...options, db }) } type ContractResponse = { $ref?: string | undefined content?: Record | undefined } type JsonSchema = { $ref?: string | undefined additionalProperties?: boolean | undefined examples?: readonly unknown[] | undefined properties?: Record | undefined required?: readonly string[] | undefined } type ComponentSpec = { components: { schemas: Record } } function dereference(spec: ComponentSpec, schema: JsonSchema): JsonSchema { if (!schema.$ref) return schema const name = schema.$ref.split('/').at(-1)! return spec.components.schemas[name]! } describe('GET /funding/transfers', () => { test('requires the funding:read scope', async () => { const app = transfersApp() expect((await app.request('/v1/funding/transfers')).status).toBe(401) const forbidden = await app.request('/v1/funding/transfers', as(TestApp.key)) expect(forbidden.status).toBe(403) }) test('pages own transfers newest-first', async () => { const app = transfersApp() const first = await seedTransfer() const second = await seedTransfer() const third = await seedTransfer() await seedTransfer({ orgId: fundingForeign.orgId }) const response = await app.request('/v1/funding/transfers?limit=5', { headers: { 'tempo-api-key': fundingReader.token }, }) expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe(Cache.policies.feed) const etag = response.headers.get('etag') ?? '' expect(etag).not.toBe('') expect(response.headers.get('vary')).toBe( 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key', ) const body = await TestApp.json(response, Funding.schema.listFundingTransfers.Response) expect(body.data.map((transfer) => transfer.id)).toEqual([third.id, second.id, first.id]) expect(body.nextCursor).toBeNull() expect(body.meta).toBeUndefined() const unchanged = await app.request('/v1/funding/transfers?limit=5', { headers: { 'if-none-match': etag, 'tempo-api-key': fundingReader.token }, }) expect(unchanged.status).toBe(304) expect(unchanged.headers.get('cache-control')).toBe(Cache.policies.feed) expect(unchanged.headers.get('vary')).toBe( 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key', ) const paged = await TestApp.json( await app.request(`/v1/funding/transfers?limit=5&cursor=${second.id}`, as(fundingReader)), Funding.schema.listFundingTransfers.Response, ) expect(paged.data.map((transfer) => transfer.id)).toEqual([first.id]) }) test('filters by status and embeds the capped count on request', async () => { const app = transfersApp() const expired = await seedTransfer() await FundingTransfer.transition(db, { expectedVersion: 1, id: expired.id, status: 'expired', }) const response = await app.request( '/v1/funding/transfers?status=expired&include=totalCount', as(fundingReader), ) const body = await TestApp.json(response, Funding.schema.listFundingTransfers.Response) expect(body.data.map((transfer) => transfer.id)).toEqual([expired.id]) expect(body.data[0]?.status).toBe('expired') expect(body.meta).toEqual({ totalCount: 1, totalCountCapped: false }) }) test('narrows project-attributed keys to their project', async () => { const app = transfersApp() const scoped = await seedTransfer({ projectId: fundingProjectReader.projectId }) const body = await TestApp.json( await app.request('/v1/funding/transfers?limit=200', as(fundingProjectReader)), Funding.schema.listFundingTransfers.Response, ) expect(body.data.map((transfer) => transfer.id)).toEqual([scoped.id]) // The organization-attributed key sees the project row too. const orgBody = await TestApp.json( await app.request('/v1/funding/transfers?limit=200', as(fundingReader)), Funding.schema.listFundingTransfers.Response, ) expect(orgBody.data.map((transfer) => transfer.id)).toContain(scoped.id) }) test('rejects invalid query parameters', async () => { const app = transfersApp() const response = await app.request('/v1/funding/transfers?status=bogus', as(fundingReader)) expect(response.status).toBe(400) }) }) describe('GET /funding/transfers/:id', () => { test('returns the owned transfer directly, without an action', async () => { const app = transfersApp() const record = await seedTransfer() const response = await app.request(`/v1/funding/transfers/${record.id}`, { headers: { 'tempo-api-key': fundingReader.token }, }) expect(response.status).toBe(200) expect({ cacheControl: response.headers.get('cache-control'), etag: response.headers.get('etag'), }).toMatchInlineSnapshot(` { "cacheControl": "no-store", "etag": null, } `) expect(response.headers.get('vary')).toBe( 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key', ) const body = await TestApp.json(response, Funding.schema.getFundingTransfer.Response) expect(body).toEqual(FundingTransfer.toPublic(record)) expect('action' in body).toBe(false) const repeated = await app.request(`/v1/funding/transfers/${record.id}`, { headers: { 'if-none-match': '"stale"', 'tempo-api-key': fundingReader.token }, }) expect({ cacheControl: repeated.headers.get('cache-control'), etag: repeated.headers.get('etag'), status: repeated.status, }).toMatchInlineSnapshot(` { "cacheControl": "no-store", "etag": null, "status": 200, } `) }) test('hides foreign and absent transfers behind the same 404', async () => { const app = transfersApp() const record = await seedTransfer() const foreign = await app.request(`/v1/funding/transfers/${record.id}`, as(fundingForeign)) expect(foreign.status).toBe(404) expect(((await foreign.json()) as { error: { code: string } }).error.code).toBe( 'funding_transfer_not_found', ) const project = await app.request( `/v1/funding/transfers/${record.id}`, as(fundingProjectReader), ) expect(project.status).toBe(404) const absent = await app.request( '/v1/funding/transfers/ftr_000000000000000_000000000000000000000000', as(fundingReader), ) expect(absent.status).toBe(404) }) }) describe('POST /funding/transfers/:id/source-transactions', () => { test('requires funding write access and validates the transaction hash', async () => { const app = transfersApp() const transfer = await seedTransfer() const path = `/v1/funding/transfers/${transfer.id}/source-transactions` expect((await app.request(path, { method: 'POST' })).status).toBe(401) expect((await app.request(path, { ...as(fundingReader), method: 'POST' })).status).toBe(403) expect( ( await app.request(path, { body: JSON.stringify({ transactionHash: '0xinvalid' }), headers: { ...as(fundingWriter).headers, 'content-type': 'application/json' }, method: 'POST', }) ).status, ).toBe(400) }) test('reports when transfer reconciliation is not configured', async () => { const app = transfersApp() const transfer = await seedTransfer() const response = await app.request(`/v1/funding/transfers/${transfer.id}/source-transactions`, { body: JSON.stringify({ transactionHash: `0x${'aa'.repeat(32)}` }), headers: { ...as(fundingWriter).headers, 'content-type': 'application/json' }, method: 'POST', }) expect(response.status).toBe(501) }) test('does not expose a transfer owned by another organization', async () => { const tracker = FundingTransferReconciliation.createTracker({ db }) const app = transfersApp({ funding: { async dispatchTransferReconciliation(message) { await tracker.reconcile(message) }, }, }) const transfer = await seedTransfer() const response = await app.request(`/v1/funding/transfers/${transfer.id}/source-transactions`, { body: JSON.stringify({ transactionHash: `0x${'aa'.repeat(32)}` }), headers: { ...as(fundingForeign).headers, 'content-type': 'application/json' }, method: 'POST', }) expect(response.status).toBe(404) }) }) const createBody = { amount: '1000000', destinationToken: '0x20c0000000000000000000000000000000000000', mode: 'exactSource', recipient: `0x${'11'.repeat(20)}`, sender: `0x${'22'.repeat(20)}`, slippageBps: 300, sourceChain: 'base', sourceToken: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', } const vaultTransferBody = { amount: createBody.amount, mode: createBody.mode, recipient: createBody.recipient, sender: createBody.sender, slippageBps: createBody.slippageBps, sourceChain: createBody.sourceChain, sourceToken: createBody.sourceToken, vaultAddress: `0x${'f4'.repeat(20)}`, } const zoneTransferBody = { amount: createBody.amount, destinationChain: 'eip155:421700001', destinationToken: createBody.destinationToken, mode: createBody.mode, recipient: createBody.recipient, recipientFallback: `0x${'33'.repeat(20)}`, sender: createBody.sender, slippageBps: createBody.slippageBps, sourceChain: createBody.sourceChain, sourceToken: createBody.sourceToken, } function postTransfer( app: ReturnType, options: { body?: unknown; idempotencyKey?: string | undefined; key?: { token: string } } = {}, ) { return app.request('/v1/funding/transfers', { body: JSON.stringify(options.body ?? createBody), headers: { authorization: `Bearer ${(options.key ?? fundingWriter).token}`, 'content-type': 'application/json', ...(options.idempotencyKey === undefined ? {} : { 'idempotency-key': options.idempotencyKey }), }, method: 'POST', }) } describe('POST /funding/transfers', () => { function providerResponse(): FundingProvider.prepareTransfer.ReturnType { const spender = `0x${'44'.repeat(20)}` return { action: { calls: [ { data: `0x095ea7b3${spender.slice(2).padStart(64, '0')}${(1_000_000).toString(16).padStart(64, '0')}`, to: createBody.sourceToken, value: '0x0', }, { data: '0xe8017952', to: spender, value: '0x0' }, ], type: 'evm:calls', }, correlation: { requestId: `0x${'ab'.repeat(32)}` }, fees: [{ amount: '21050', side: 'source' }], destinationAmountMin: '980000', destinationAmount: '990000', quality: { estimatedSeconds: 4, liquiditySource: 'providerQuote', sourceDetail: 'relay:quote-v2', tier: 'liquid', }, sampledAt: '2099-01-01T00:00:00.000Z', } } function provider( prepareTransfer: NonNullable = async () => providerResponse(), ) { return FundingProvider.from({ ...Relay.relay(), prepareTransfer, }) } let idempotencySequence = 0 function nextIdempotencyKey() { return `funding_test_${idempotencySequence++}` } test('requires funding:write and an Idempotency-Key', async () => { const app = transfersApp({ providers: [provider()], }) const forbidden = await postTransfer(app, { idempotencyKey: nextIdempotencyKey(), key: fundingReader, }) expect(forbidden.status).toBe(403) const missing = await postTransfer(app) expect(missing.status).toBe(400) expect(((await missing.json()) as { error: { code: string } }).error.code).toBe( 'idempotency_key_required', ) }) test('creates a transfer with the selected executable quote', async () => { const inputs: FundingProvider.prepareTransfer.Parameters[] = [] const app = transfersApp({ providers: [ provider(async (input) => { inputs.push(input) return providerResponse() }), ], }) const response = await postTransfer(app, { body: { ...createBody, destinationToken: 'pathusd', sourceToken: 'usdc' }, idempotencyKey: nextIdempotencyKey(), }) expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') const body = await TestApp.json(response, Funding.schema.createFundingTransfer.Response) expect(inputs[0]).toMatchObject({ method: 'transaction', mode: 'exactSource', recipient: createBody.recipient, sender: createBody.sender, slippageBps: 300, }) expect(body).toMatchObject({ action: { type: 'evm:calls' }, destinationAmountMin: { baseUnits: '980000' }, method: 'transaction', mode: 'exactSource', provider: { id: 'relay' }, // The provider returned no expiry, so the default validity window applies. quote: { expiresAt: '2099-01-01T00:01:00.000Z', sampledAt: '2099-01-01T00:00:00.000Z' }, sourceAmount: { baseUnits: '1000000' }, status: 'awaiting-source', version: 1, }) expect(body.fees[0]).toMatchObject({ side: 'source', token: { symbol: 'USDC' } }) // Reads return the same transfer without the action; the private // correlation never leaves the row. const read = await app.request(`/v1/funding/transfers/${body.id}`, as(fundingWriter)) const readBody = await TestApp.json(read, Funding.schema.getFundingTransfer.Response) expect('action' in readBody).toBe(false) expect(JSON.stringify(readBody)).not.toContain('ab'.repeat(32)) const row = await db.kysely .selectFrom('funding_transfers') .select(['providerState']) .where('id', '=', body.id) .executeTakeFirstOrThrow() expect(row.providerState).toEqual({ requestId: `0x${'ab'.repeat(32)}` }) }) test('dispatches staged webhook updates after creation commits', async () => { const subscription = await Webhooks.createSubscription(db, { chainId: tempoMainnet.id, destination: { type: 'url', url: 'https://example.com/webhooks/funding-transfers' }, environment: 'production', eventType: 'funding:transfer.updated', filters: { status: 'awaiting-source' }, owner: { orgId: fundingWriter.orgId, type: 'api_key' }, }) const references: Webhooks.QueueReference[] = [] const app = transfersApp({ funding: { async dispatchTransferUpdates(staged) { const reference = staged[0]! const queued = await db.kysely .selectFrom('webhook_queue_events') .select('eventId') .where('eventId', '=', reference.eventId) .where('subscriptionId', '=', reference.subscriptionId) .executeTakeFirst() expect(queued).toBeDefined() references.push(...staged) return staged.length }, }, providers: [provider()], }) const response = await postTransfer(app, { idempotencyKey: nextIdempotencyKey() }) expect(response.status).toBe(200) expect(references).toEqual([{ eventId: expect.any(String), subscriptionId: subscription.id }]) }) test('creates a transfer with the requested provider', async () => { const app = transfersApp({ providers: [provider()] }) const response = await postTransfer(app, { body: { ...createBody, provider: 'relay' }, idempotencyKey: nextIdempotencyKey(), }) expect(response.status).toBe(200) const body = await TestApp.json(response, Funding.schema.createFundingTransfer.Response) expect(body.provider.id).toBe('relay') }) test('rejects an unsupported provider', async () => { const app = transfersApp({ providers: [provider()] }) const response = await postTransfer(app, { body: { ...createBody, provider: 'bridge' }, idempotencyKey: nextIdempotencyKey(), }) expect(response.status).toBe(400) expect(((await response.json()) as { error: { code: string } }).error.code).toBe('body_invalid') }) test('preserves a provider-specified native fee token', async () => { const app = transfersApp({ providers: [ provider(async () => ({ ...providerResponse(), fees: [ { amount: '1000000000000000', side: 'source', token: { address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', currency: 'ETH', decimals: 18, name: 'Ether', standard: 'native', symbol: 'ETH', tokenKey: 'eip155:8453/slip44:60', verified: true, }, }, ], })), ], }) const response = await postTransfer(app, { body: { ...createBody, destinationToken: 'pathusd', sourceToken: 'usdc' }, idempotencyKey: nextIdempotencyKey(), }) expect(response.status).toBe(200) const body = await TestApp.json(response, Funding.schema.createFundingTransfer.Response) expect(body.fees).toMatchObject([ { amount: { baseUnits: '1000000000000000', formatted: '0.001' }, side: 'source', token: { standard: 'native', symbol: 'ETH' }, }, ]) }) test('resolves token identifiers through the composed route', async () => { const app = transfersApp({ providers: [provider()] }) const identifiers = [ { destinationToken: 'pathusd', name: 'alias', sourceToken: 'usdc' }, { destinationToken: createBody.destinationToken, name: 'address', sourceToken: createBody.sourceToken, }, { destinationToken: 'eip155:4217/erc20:0x20c0000000000000000000000000000000000000', name: 'token key', sourceToken: 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', }, ] as const const results = [] for (const identifier of identifiers) { const response = await postTransfer(app, { body: { ...createBody, destinationToken: identifier.destinationToken, sourceToken: identifier.sourceToken, }, idempotencyKey: nextIdempotencyKey(), }) const body = await TestApp.json(response, Funding.schema.createFundingTransfer.Response) results.push({ destinationToken: body.destinationToken.symbol, name: identifier.name, sourceToken: body.sourceToken.symbol, status: response.status, }) } expect(results).toMatchInlineSnapshot(` [ { "destinationToken": "pathUSD", "name": "alias", "sourceToken": "USDC", "status": 200, }, { "destinationToken": "pathUSD", "name": "address", "sourceToken": "USDC", "status": 200, }, { "destinationToken": "pathUSD", "name": "token key", "sourceToken": "USDC", "status": 200, }, ] `) }) test('replays the exact response for the same key and body', async () => { const app = transfersApp({ providers: [provider()], }) const idempotencyKey = nextIdempotencyKey() const first = await postTransfer(app, { idempotencyKey }) const second = await postTransfer(app, { idempotencyKey }) expect(second.status).toBe(200) expect(await second.json()).toEqual(await first.json()) }) test('rejects a reused key with different input', async () => { const app = transfersApp({ providers: [provider()], }) const idempotencyKey = nextIdempotencyKey() await postTransfer(app, { idempotencyKey }) const conflict = await postTransfer(app, { body: { ...createBody, amount: '2000000' }, idempotencyKey, }) expect(conflict.status).toBe(409) expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe( 'idempotency_conflict', ) }) test('rejects an equivalent request while the first is running', async () => { const app = transfersApp({ providers: [provider()], }) const idempotencyKey = nextIdempotencyKey() await FundingIdempotencyTable.claim(db, { apiKeyId: fundingWriter.id, keyHash: Idempotency.inputHash(idempotencyKey), requestHash: Idempotency.inputHash(createBody), ttlMs: 60_000, }) const pending = await postTransfer(app, { idempotencyKey }) expect(pending.status).toBe(409) expect(((await pending.json()) as { error: { code: string } }).error.code).toBe( 'idempotency_in_progress', ) }) test('rolls back creation after losing the idempotency lease', async () => { const idempotencyKey = nextIdempotencyKey() const keyHash = Idempotency.inputHash(idempotencyKey) const before = await db.kysely .selectFrom('funding_transfers') .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirstOrThrow() const app = transfersApp({ providers: [ provider(async () => { await db.kysely .updateTable('funding_idempotency_requests') .set({ expiresAt: new Date(0).toISOString() }) .where('apiKeyId', '=', fundingWriter.id) .where('keyHash', '=', keyHash) .execute() await FundingIdempotencyTable.claim(db, { apiKeyId: fundingWriter.id, keyHash, requestHash: Idempotency.inputHash(createBody), ttlMs: 60_000, }) return providerResponse() }), ], }) const response = await postTransfer(app, { idempotencyKey }) expect(response.status).toBe(500) const after = await db.kysely .selectFrom('funding_transfers') .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirstOrThrow() expect(after.count).toBe(before.count) }) test('rejects non-account fields and gates disabled corridors and methods', async () => { const app = transfersApp({ providers: [provider()], }) const ignoredRefund = await postTransfer(app, { body: { ...createBody, refundAddress: createBody.sender }, idempotencyKey: nextIdempotencyKey(), }) expect(ignoredRefund.status).toBe(400) expect(((await ignoredRefund.json()) as { error: { code: string } }).error.code).toBe( 'body_invalid', ) const unsupportedDestination = await postTransfer(app, { body: { ...createBody, destinationVault: `0x${'f4'.repeat(20)}`, type: 'vault' }, idempotencyKey: nextIdempotencyKey(), }) expect(unsupportedDestination.status).toBe(400) expect(((await unsupportedDestination.json()) as { error: { code: string } }).error.code).toBe( 'body_invalid', ) const unknownToken = await postTransfer(app, { body: { ...createBody, destinationToken: `0x${'99'.repeat(20)}` }, idempotencyKey: nextIdempotencyKey(), }) expect(unknownToken.status).toBe(404) }) test('releases the claim after a provider failure', async () => { const failing = transfersApp({ providers: [ provider(async () => { throw new Error('upstream unavailable') }), ], }) const idempotencyKey = nextIdempotencyKey() const failed = await postTransfer(failing, { idempotencyKey }) expect(failed.status).toBe(502) expect(((await failed.json()) as { error: { code: string } }).error.code).toBe('upstream_error') // The key was released, so the same request retries successfully. const working = transfersApp({ providers: [provider()], }) expect((await postTransfer(working, { idempotencyKey })).status).toBe(200) }) test('rejects preparations with unbounded approvals', async () => { const app = transfersApp({ providers: [ provider(async () => { const result = providerResponse() const spender = `0x${'44'.repeat(20)}` return { ...result, action: { calls: [ { data: `0x095ea7b3${spender.slice(2).padStart(64, '0')}${'f'.repeat(64)}`, to: createBody.sourceToken, value: '0x0', }, ], type: 'evm:calls', }, } }), ], }) const response = await postTransfer(app, { idempotencyKey: nextIdempotencyKey() }) expect(response.status).toBe(502) }) }) describe.skipIf(!Anvil.available)('POST /funding/transfers (forked localnet)', () => { /** * Storage slot of Circle FiatToken's `balanceAndBlacklistStates` mapping. * `anvil_dealERC20` cannot probe it because balances pack a blacklist bit, * so the fork suite writes the slot directly. Every curated USDC is a * FiatToken. */ const fiatTokenBalanceSlot = 9n for (const executable of executableProviders) { describe(executable.id, () => { const route = TestFunding.snapshot.routes.find( (entry) => entry.route.source.chain.id === `eip155:${base.id}` && entry.route.source.symbol === 'USDC' && entry.route.destination.slug === executable.destinationToken, )! const usdc = route.route.source const amount = Value.from('10', usdc.decimals) const sender = Anvil.getAccount(0) let anvil: Anvil.start.ReturnType beforeAll(async () => { anvil = await Anvil.start({ chain: base, forkUrl: base.rpcUrls.default.http[0]! }) }) afterAll(async () => { await anvil?.stop() }) function createTransfer(idempotencyKey: string) { const app = transfersApp({ providers: [executable.create(sender.address)] }) return postTransfer(app, { body: { ...createBody, amount: amount.toString(), destinationToken: executable.destinationToken, sender: sender.address, }, idempotencyKey, }) } test('executes the prepared calls and returns successful receipts', async () => { const { client } = anvil await Anvil.setErc20Balance(client, { address: sender.address, amount, slot: fiatTokenBalanceSlot, token: usdc.address as `0x${string}`, }) const response = await createTransfer(`bridge_${executable.id}_${sender.address}`) expect(response.status).toBe(200) const transfer = await TestApp.json(response, Funding.schema.createFundingTransfer.Response) expect(transfer.action.type).toBe('evm:calls') if (transfer.action.type !== 'evm:calls') return expect(transfer.action.calls.length).toBeGreaterThan(0) if (executable.id === 'stargate') { expect(transfer.fees).toMatchObject([ { amount: { baseUnits: BigInt(transfer.action.calls.at(-1)!.value).toString() }, side: 'source', token: { standard: 'native', symbol: 'ETH' }, }, ]) const row = await db.kysely .selectFrom('funding_transfers') .select('providerState') .where('id', '=', transfer.id) .executeTakeFirstOrThrow() expect(row.providerState).toEqual({ destinationBlockNumber: expect.stringMatching(/^\d+$/), routeConfiguration: stargateConfiguration, }) } const receipts = [] for (const call of transfer.action.calls) { receipts.push( await client.sendTransactionSync({ account: sender, data: call.data as `0x${string}`, to: call.to as `0x${string}`, value: BigInt(call.value), }), ) } expect(receipts.map((receipt) => receipt.status)).toEqual( transfer.action.calls.map(() => 'success'), ) // The deposit is the last call and must emit; an approval alone proves nothing. expect(receipts.at(-1)!.logs.length).toBeGreaterThan(0) if (executable.id === 'stargate') { const receipt = receipts.at(-1)! const finalized = await client.getBlock({ blockTag: 'finalized' }) expect(receipt.blockNumber).toBeGreaterThan(finalized.number) await expect( Stargate.verifySourceTransaction({ configuration: stargateConfiguration, destinationTokenAddress: transfer.destinationToken.address, recipient: transfer.recipient, sender: sender.address, sourceAmount: transfer.sourceAmount.baseUnits, sourceChain: FundingChain.from({ id: 'eip155:8453', name: 'Base', rpcUrls: [anvil.rpcUrl], slug: 'base', }), sourceTokenAddress: transfer.sourceToken.address, transactionHash: receipt.transactionHash, validAfter: transfer.quote.sampledAt, }), ).resolves.toMatchObject({ type: 'verified' }) } }) if (executable.id === 'stargate') test('reopens registration when the source transaction is reorged out', async () => { await Anvil.setErc20Balance(anvil.client, { address: sender.address, amount, slot: fiatTokenBalanceSlot, token: usdc.address as `0x${string}`, }) const response = await createTransfer(`bridge_reorg_${sender.address}`) expect(response.status).toBe(200) const transfer = await TestApp.json( response, Funding.schema.createFundingTransfer.Response, ) expect(transfer.action.type).toBe('evm:calls') if (transfer.action.type !== 'evm:calls') return const checkpoint = await anvil.client.snapshot() // Anvil increments fork timestamps instead of following wall time between tests. const block = await anvil.client.getBlock() const sampledAt = BigInt(Math.floor(Date.parse(transfer.quote.sampledAt) / 1_000)) if (block.timestamp < sampledAt) await anvil.client.setNextBlockTimestamp({ timestamp: sampledAt }) let reverted = false try { const receipts = [] for (const call of transfer.action.calls) receipts.push( await anvil.client.sendTransactionSync({ account: sender, data: call.data as `0x${string}`, to: call.to as `0x${string}`, value: BigInt(call.value), }), ) const sourceReceipt = receipts.at(-1)! const transactionHash = sourceReceipt.transactionHash await FundingCatalog.publish(db, { ...TestFunding.data, chains: TestFunding.data.chains.map((chain) => chain.id === 'eip155:8453' ? { ...chain, rpcUrls: [anvil.rpcUrl] } : chain, ), }) const messages: FundingTransferReconciliation.Message[] = [] const app = transfersApp({ funding: { async dispatchTransferReconciliation(message) { messages.push(message) }, }, providers: [Stargate.stargate()], }) const registered = await app.request( `/v1/funding/transfers/${transfer.id}/source-transactions`, { body: JSON.stringify({ transactionHash }), headers: { ...as(fundingWriter).headers, 'content-type': 'application/json' }, method: 'POST', }, ) expect(registered.status).toBe(200) expect(messages).toEqual([ { transferId: transfer.id, type: 'funding:transfer:reconcile' }, ]) await anvil.client.revert({ id: checkpoint }) reverted = true const tracker = FundingTransferReconciliation.createTracker({ db, async dispatch(message) { messages.push(message) }, }) await expect(tracker.reconcile(messages[0]!)).resolves.toEqual({ type: 'pending' }) expect(messages[1]).toEqual({ attempt: 1, transferId: transfer.id, type: 'funding:transfer:reconcile', }) const pending = await db.kysely .selectFrom('funding_transfers') .select(['snapshot', 'status', 'version']) .where('id', '=', transfer.id) .executeTakeFirstOrThrow() expect({ sourceTransactionHashes: pending.snapshot.sourceTransactionHashes, status: pending.status, version: pending.version, }).toEqual({ sourceTransactionHashes: [transactionHash], status: 'processing', version: 2, }) await anvil.client.mine({ blocks: 128 }) const finalized = await anvil.client.getBlock({ blockTag: 'finalized' }) expect(finalized.number).toBeGreaterThanOrEqual(sourceReceipt.blockNumber) await expect(tracker.reconcile(messages[1]!)).resolves.toEqual({ type: 'reopened' }) const record = await db.kysely .selectFrom('funding_transfers') .select(['snapshot', 'status', 'version']) .where('id', '=', transfer.id) .executeTakeFirstOrThrow() expect({ sourceTransactionHashes: record.snapshot.sourceTransactionHashes, status: record.status, version: record.version, }).toEqual({ sourceTransactionHashes: undefined, status: 'awaiting-source', version: 3, }) expect( await db.kysely .selectFrom('funding_transfer_transactions') .selectAll() .where('transferId', '=', transfer.id) .execute(), ).toEqual([]) } finally { if (!reverted) await anvil.client.revert({ id: checkpoint }) await TestFunding.publish(db) } }) test('bounds the approval to the quoted source spend', async () => { const response = await createTransfer(`bridge_bounds_${executable.id}_${sender.address}`) expect(response.status).toBe(200) const transfer = await TestApp.json(response, Funding.schema.createFundingTransfer.Response) if (transfer.action.type !== 'evm:calls') return const approval = transfer.action.calls.find((call) => call.data.toLowerCase().startsWith('0x095ea7b3'), ) expect(approval).toBeDefined() if (!approval) return // Decoded from the live action, so a provider that starts returning // an unlimited allowance fails here. const decoded = decodeFunctionData({ abi: erc20Abi, data: approval.data as `0x${string}` }) expect(decoded.functionName).toBe('approve') if (decoded.functionName !== 'approve') return const [, approved] = decoded.args expect(approved).toBeLessThanOrEqual(BigInt(transfer.sourceAmount.baseUnits)) expect(approved).toBeLessThan(maxUint256) }) }) } }) describe.skipIf(!Anvil.available)( 'POST /funding/transfers/:id/source-transactions (forked localnet)', () => { const trackingDb = TestApp.database() let destination: Anvil.start.ReturnType let source: Anvil.start.ReturnType beforeAll(async () => { ;[destination, source] = await Promise.all([ Anvil.start({ chain: tempoMainnet, forkUrl: tempoMainnet.rpcUrls.default.http[0]! }), Anvil.start({ chain: base, forkUrl: base.rpcUrls.default.http[0]! }), ]) await FundingCatalog.publish(trackingDb, { ...TestFunding.data, chains: TestFunding.data.chains.map((chain) => { if (chain.id === 'eip155:4217') return { ...chain, rpcUrls: [destination.rpcUrl] } if (chain.id === 'eip155:8453') return { ...chain, rpcUrls: [source.rpcUrl] } return chain }), }) }) afterAll(async () => { await Promise.all([destination?.stop(), source?.stop()]) }) test('backfills legacy state and completes from destination evidence', async () => { const destinationReceipt = await destination.client.getTransactionReceipt({ hash: destinationTransactionHash, }) const sourceTransactionHash = '0xfd71359adf5095f2ab512a19e0a2df6c8cc0db14ac8a52286d61287daabdccd4' const sourceReceipt = await source.client.getTransactionReceipt({ hash: sourceTransactionHash, }) const sourceBlock = await source.client.getBlock({ blockNumber: sourceReceipt.blockNumber }) const sampledAt = new Date(Number(sourceBlock.timestamp - 1n) * 1_000).toISOString() const amount = { baseUnits: '200000', currency: 'USD', decimals: 6, formatted: '0.2', } as const const snapshot = { ...TestFunding.transferSnapshot({ destinationAmount: amount, destinationAmountMin: amount, provider: { id: 'stargate', name: 'Stargate' }, recipient: '0x7212623e4cff9ad010d28aa065fad2bf58578783', sender: '0x52b650a62e384f68880d0a1dc2b84dfed47d5660', sourceAmount: amount, }), quote: { expiresAt: new Date(Date.parse(sampledAt) + 60_000).toISOString(), sampledAt, }, } const transfer = await FundingTransfer.create(trackingDb, { apiKeyId: fundingWriter.id, environment: 'production', orgId: fundingWriter.orgId, snapshot, }) const tracker = FundingTransferReconciliation.createTracker({ db: trackingDb }) const messages: FundingTransferReconciliation.Message[] = [] let dispatchEnabled = true const app = TestApp.create({ auth: { keys: [fundingWriter] }, db: trackingDb, defaultChainId: tempoMainnet.id, funding: { async dispatchTransferReconciliation(message) { if (!dispatchEnabled) throw new Error('reconciliation unavailable') messages.push(message) }, }, providers: [Stargate.stargate()], }) const path = `/v1/funding/transfers/${transfer.id}/source-transactions` const registration = { body: JSON.stringify({ transactionHash: sourceTransactionHash, }), headers: { ...as(fundingWriter).headers, 'content-type': 'application/json' }, method: 'POST', } const response = await app.request(path, registration) expect(response.status).toBe(200) const registered = await TestApp.json( response, Funding.schema.registerFundingTransferSourceTransaction.Response, ) expect({ sourceTransactionHashes: registered.sourceTransactionHashes, status: registered.status, version: registered.version, }).toEqual({ sourceTransactionHashes: [ '0xfd71359adf5095f2ab512a19e0a2df6c8cc0db14ac8a52286d61287daabdccd4', ], status: 'processing', version: 2, }) const stored = await trackingDb.kysely .selectFrom('funding_transfers') .select('providerState') .where('id', '=', transfer.id) .executeTakeFirstOrThrow() expect(stored).toEqual({ providerState: { destinationBlockNumber: expect.stringMatching(/^\d+$/), routeConfiguration: stargateConfiguration, stargate: { guid: '0x5549d117c8b23f0ecdf05c2604ed989cf0034f378707bd81319e93834f2f85f2', sourceBlock: { hash: sourceReceipt.blockHash, number: sourceReceipt.blockNumber.toString(), }, sourceEid: 30_184, }, }, }) const destinationBlockNumber = stored.providerState?.['destinationBlockNumber'] if (typeof destinationBlockNumber !== 'string') throw new Error('Expected a backfilled destination block number.') expect(BigInt(destinationBlockNumber)).toBeLessThanOrEqual(destinationReceipt.blockNumber) expect(messages).toEqual([{ transferId: transfer.id, type: 'funding:transfer:reconcile' }]) await tracker.reconcile(messages[0]!) const completed = await TestApp.json( await app.request(`/v1/funding/transfers/${transfer.id}`, as(fundingWriter)), Funding.schema.getFundingTransfer.Response, ) expect({ destinationTransactionHashes: completed.destinationTransactionHashes, status: completed.status, version: completed.version, }).toEqual({ destinationTransactionHashes: [destinationTransactionHash], status: 'completed', version: 3, }) dispatchEnabled = false const replayed = await TestApp.json( await app.request(path, registration), Funding.schema.registerFundingTransferSourceTransaction.Response, ) expect({ status: replayed.status, version: replayed.version }).toEqual({ status: 'completed', version: 3, }) const other = await FundingTransfer.create(trackingDb, { apiKeyId: fundingWriter.id, environment: 'production', orgId: fundingWriter.orgId, snapshot, }) const conflict = await app.request( `/v1/funding/transfers/${other.id}/source-transactions`, registration, ) expect(conflict.status).toBe(409) expect(((await conflict.json()) as { error: { code: string } }).error.code).toBe( 'source_transaction_conflict', ) const pending = await FundingTransfer.create(trackingDb, { apiKeyId: fundingWriter.id, environment: 'production', orgId: fundingWriter.orgId, snapshot, }) const pendingResponse = await app.request( `/v1/funding/transfers/${pending.id}/source-transactions`, { ...registration, body: JSON.stringify({ transactionHash: `0x${'99'.repeat(32)}` }), }, ) expect(pendingResponse.status).toBe(409) expect(((await pendingResponse.json()) as { error: { code: string } }).error.code).toBe( 'source_transaction_pending', ) }) }, ) describe('POST /funding/transfers/{destination}', () => { test.each([ { body: vaultTransferBody, destination: 'vault' }, { body: zoneTransferBody, destination: 'zone' }, ] as const)('validates and returns 501 for $destination transfers', async (options) => { const app = transfersApp() const before = await db.kysely .selectFrom('funding_transfers') .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirstOrThrow() const response = await app.request(`/v1/funding/transfers/${options.destination}`, { body: JSON.stringify(options.body), headers: { authorization: `Bearer ${fundingWriter.token}`, 'content-type': 'application/json', 'idempotency-key': `funding_${options.destination}_unimplemented`, }, method: 'POST', }) expect(response.status).toBe(501) expect(response.headers.get('cache-control')).toBe('no-store') expect(((await response.json()) as { error: { code: string } }).error.code).toBe( 'not_implemented', ) const after = await db.kysely .selectFrom('funding_transfers') .select(({ fn }) => fn.countAll().as('count')) .executeTakeFirstOrThrow() expect(after.count).toBe(before.count) }) test('requires the header and validates destination fields', async () => { const app = transfersApp() const missingHeader = await app.request('/v1/funding/transfers/vault', { body: JSON.stringify(vaultTransferBody), headers: { authorization: `Bearer ${fundingWriter.token}`, 'content-type': 'application/json', }, method: 'POST', }) expect(missingHeader.status).toBe(400) expect(((await missingHeader.json()) as { error: { code: string } }).error.code).toBe( 'idempotency_key_required', ) const invalidZone = await app.request('/v1/funding/transfers/zone', { body: JSON.stringify({ ...zoneTransferBody, destinationChain: '421700001' }), headers: { authorization: `Bearer ${fundingWriter.token}`, 'content-type': 'application/json', 'idempotency-key': 'funding_zone_invalid', }, method: 'POST', }) expect(invalidZone.status).toBe(400) expect(((await invalidZone.json()) as { error: { code: string } }).error.code).toBe( 'body_invalid', ) }) }) describe('funding transfers OpenAPI', () => { test('publishes generator-ready transfer contracts', async () => { const spec = await (await create({ auth: false }).request('/openapi.json')).json() const detailPath = Object.keys(spec.paths).find( (path) => path.includes('/v1/funding/transfers/{') && !path.endsWith('/source-transactions'), )! const sourceTransactionPath = Object.keys(spec.paths).find((path) => path.endsWith('/source-transactions'), )! const operations = { create: spec.paths['/v1/funding/transfers'].post, createVault: spec.paths['/v1/funding/transfers/vault'].post, createZone: spec.paths['/v1/funding/transfers/zone'].post, get: spec.paths[detailPath].get, list: spec.paths['/v1/funding/transfers'].get, registerSourceTransaction: spec.paths[sourceTransactionPath].post, } const summarize = (operation: (typeof operations)[keyof typeof operations]) => ({ errors: Object.fromEntries( (Object.entries(operation.responses) as [string, ContractResponse][]) .filter(([status]) => Number(status) >= 400) .map(([status, response]) => [ status, response.$ref ?? response.content?.['application/json']?.schema, ]), ), operationId: operation.operationId, request: operation.requestBody?.content['application/json'].schema, response: operation.responses[200]?.content?.['application/json']?.schema, }) expect({ components: [ 'CreateFundingTransferRequest', 'CreateFundingTransferVaultRequest', 'CreateFundingTransferZoneRequest', 'CreatedFundingTransfer', 'FundingAction', 'FundingActionCall', 'FundingEvmCallsAction', 'FundingTransfer', 'FundingTransferFee', 'FundingTransferList', 'FundingTransferQuote', 'FundingTransferStatusReason', 'RegisterFundingTransferSourceTransactionRequest', ].filter((name) => spec.components.schemas[name]), formats: { createdAt: spec.components.schemas.FundingTransfer.properties.createdAt.format, quoteExpiresAt: spec.components.schemas.FundingTransferQuote.properties.expiresAt.format, quoteSampledAt: spec.components.schemas.FundingTransferQuote.properties.sampledAt.format, updatedAt: spec.components.schemas.FundingTransfer.properties.updatedAt.format, }, models: { action: spec.components.schemas.FundingAction.oneOf, createdAction: spec.components.schemas.CreatedFundingTransfer.properties.action, evmCall: spec.components.schemas.FundingEvmCallsAction.properties.calls.items, feeToken: spec.components.schemas.FundingTransferFee.properties.token, list: spec.components.schemas.FundingTransferList.properties.data.items, quote: spec.components.schemas.FundingTransfer.properties.quote, statusReason: spec.components.schemas.FundingTransfer.properties.statusReason, }, operations: { create: summarize(operations.create), createVault: summarize(operations.createVault), createZone: summarize(operations.createZone), get: summarize(operations.get), list: summarize(operations.list), registerSourceTransaction: summarize(operations.registerSourceTransaction), }, operationErrorCodes: { createInvalid: spec.components.schemas.ApiKeyMalformedOrBodyInvalidOrIdempotencyKeyRequiredError .properties.error.properties.code.enum, idempotencyConflict: spec.components.schemas.IdempotencyConflictOrIdempotencyInProgressError.properties.error .properties.code.enum, notImplemented: spec.components.schemas.NotImplemented501Error.properties.error.properties.code.enum, parametersInvalid: spec.components.schemas.ApiKeyMalformedOrParamInvalidError.properties.error.properties .code.enum, queryInvalid: spec.components.schemas.ApiKeyMalformedOrQueryInvalidError.properties.error.properties .code.enum, quoteNotAvailable: spec.components.schemas.QuoteNotAvailableError.properties.error.properties.code.enum, transferNotFound: spec.components.schemas.FundingTransferNotFoundError.properties.error.properties.code .enum, }, }).toMatchInlineSnapshot(` { "components": [ "CreateFundingTransferRequest", "CreateFundingTransferVaultRequest", "CreateFundingTransferZoneRequest", "CreatedFundingTransfer", "FundingAction", "FundingActionCall", "FundingEvmCallsAction", "FundingTransfer", "FundingTransferFee", "FundingTransferList", "FundingTransferQuote", "FundingTransferStatusReason", "RegisterFundingTransferSourceTransactionRequest", ], "formats": { "createdAt": "date-time", "quoteExpiresAt": "date-time", "quoteSampledAt": "date-time", "updatedAt": "date-time", }, "models": { "action": [ { "$ref": "#/components/schemas/FundingEvmCallsAction", }, ], "createdAction": { "$ref": "#/components/schemas/FundingAction", }, "evmCall": { "$ref": "#/components/schemas/FundingActionCall", }, "feeToken": { "$ref": "#/components/schemas/FundingToken", }, "list": { "$ref": "#/components/schemas/FundingTransfer", }, "quote": { "$ref": "#/components/schemas/FundingTransferQuote", }, "statusReason": { "$ref": "#/components/schemas/FundingTransferStatusReason", }, }, "operationErrorCodes": { "createInvalid": [ "api_key_malformed", "body_invalid", "idempotency_key_required", ], "idempotencyConflict": [ "idempotency_conflict", "idempotency_in_progress", ], "notImplemented": [ "not_implemented", ], "parametersInvalid": [ "api_key_malformed", "param_invalid", ], "queryInvalid": [ "api_key_malformed", "query_invalid", ], "quoteNotAvailable": [ "quote_not_available", ], "transferNotFound": [ "funding_transfer_not_found", ], }, "operations": { "create": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrIdempotencyKeyRequiredError", }, "401": { "$ref": "#/components/schemas/AuthenticationError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/QuoteNotAvailableError", }, "409": { "$ref": "#/components/schemas/IdempotencyConflictOrIdempotencyInProgressError", }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "$ref": "#/components/schemas/UpstreamError", }, "504": "#/components/responses/RequestTimeout", }, "operationId": "createFundingTransfer", "request": { "$ref": "#/components/schemas/CreateFundingTransferRequest", }, "response": { "$ref": "#/components/schemas/CreatedFundingTransfer", }, }, "createVault": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrIdempotencyKeyRequiredError", }, "401": { "$ref": "#/components/schemas/AuthenticationError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "501": { "$ref": "#/components/schemas/NotImplemented501Error", }, "504": "#/components/responses/RequestTimeout", }, "operationId": "createFundingTransferVault", "request": { "$ref": "#/components/schemas/CreateFundingTransferVaultRequest", }, "response": undefined, }, "createZone": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrIdempotencyKeyRequiredError", }, "401": { "$ref": "#/components/schemas/AuthenticationError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "501": { "$ref": "#/components/schemas/NotImplemented501Error", }, "504": "#/components/responses/RequestTimeout", }, "operationId": "createFundingTransferZone", "request": { "$ref": "#/components/schemas/CreateFundingTransferZoneRequest", }, "response": undefined, }, "get": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "401": { "$ref": "#/components/schemas/AuthenticationError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/FundingTransferNotFoundError", }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "$ref": "#/components/schemas/UpstreamError", }, "504": "#/components/responses/RequestTimeout", }, "operationId": "getFundingTransfer", "request": undefined, "response": { "$ref": "#/components/schemas/FundingTransfer", }, }, "list": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrQueryInvalidError", }, "401": { "$ref": "#/components/schemas/AuthenticationError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "$ref": "#/components/schemas/UpstreamError", }, "504": "#/components/responses/RequestTimeout", }, "operationId": "listFundingTransfers", "request": undefined, "response": { "$ref": "#/components/schemas/FundingTransferList", }, }, "registerSourceTransaction": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidOrSourceTransactionInvalidError", }, "401": { "$ref": "#/components/schemas/AuthenticationError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/FundingTransferNotFoundError", }, "409": { "$ref": "#/components/schemas/FundingTransferNotAwaitingSourceOrSourceTransactionConflictOrSourceTransactionPendingError", }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "501": { "$ref": "#/components/schemas/NotImplemented501Error", }, "502": { "$ref": "#/components/schemas/UpstreamError", }, "504": "#/components/responses/RequestTimeout", }, "operationId": "registerFundingTransferSourceTransaction", "request": { "$ref": "#/components/schemas/RegisterFundingTransferSourceTransactionRequest", }, "response": { "$ref": "#/components/schemas/FundingTransfer", }, }, }, } `) }) test('publishes an executable account transfer example', async () => { const app = create({ auth: false }) type TransferExample = { destinationToken: { address: string } } type Operation = { requestBody: { content: Record } responses: Record }> } type Spec = ComponentSpec & { paths: Record } const spec = (await (await app.request('/openapi.json')).json()) as Spec const operation = spec.paths['/v1/funding/transfers']!.post const request = dereference(spec, operation.requestBody.content['application/json']!.schema) expect({ request: request.properties!['destinationToken']!.examples, response: operation.responses['200']!.content['application/json']!.example.destinationToken.address, }).toEqual({ request: ['pathusd'], response: '0x20c0000000000000000000000000000000000000', }) }) test('publishes human-readable deposit address route examples', async () => { const app = create({ auth: false }) type Operation = { requestBody: { content: Record } } type Spec = ComponentSpec & { paths: Record } const spec = (await (await app.request('/openapi.json')).json()) as Spec const properties = dereference( spec, spec.paths['/v1/funding/deposit-addresses']!.post.requestBody.content['application/json']! .schema, ).properties! expect({ destinationToken: properties['destinationToken']!.examples, sourceChain: properties['sourceChain']!.examples, sourceToken: properties['sourceToken']!.examples, }).toMatchInlineSnapshot(` { "destinationToken": [ "usdt0", ], "sourceChain": [ "tron", "tron:0x2b6653dc", ], "sourceToken": [ "usdt", ], } `) }) test('publishes concise operation documentation', async () => { const app = create({ auth: false }) type Operation = { description: string; summary: string; tags: string[] } type Spec = { paths: Record tags: { description?: string; name: string }[] 'x-tagGroups': { name: string; tags: string[] }[] } const spec = (await (await app.request('/openapi.json')).json()) as Spec const detailPath = Object.keys(spec.paths).find( (path) => path.includes('/v1/funding/transfers/{') && !path.endsWith('/source-transactions'), )! const sourceTransactionPath = Object.keys(spec.paths).find((path) => path.endsWith('/source-transactions'), )! const depositAddressPath = Object.keys(spec.paths).find( (path) => path.includes('/v1/funding/deposit-addresses/{') && !path.endsWith('/deposits') && !path.endsWith('/reconcile'), )! const depositPath = Object.keys(spec.paths).find((path) => path.includes('/v1/funding/deposits/{'), )! const reconcilePath = Object.keys(spec.paths).find((path) => path.endsWith('/reconcile'))! const summarize = (operation: Operation) => ({ description: operation.description, summary: operation.summary, }) const operations = { account: summarize(spec.paths['/v1/funding/transfers']!.post!), chains: summarize(spec.paths['/v1/funding/chains']!.get!), deposit: summarize(spec.paths[depositPath]!.get!), depositAddress: summarize(spec.paths[depositAddressPath]!.get!), depositAddressCreate: summarize(spec.paths['/v1/funding/deposit-addresses']!.post!), depositAddressList: summarize(spec.paths['/v1/funding/deposit-addresses']!.get!), depositAddressReconcile: summarize(spec.paths[reconcilePath]!.post!), deposits: summarize(spec.paths['/v1/funding/deposits']!.get!), detail: summarize(spec.paths[detailPath]!.get!), list: summarize(spec.paths['/v1/funding/transfers']!.get!), providers: summarize(spec.paths['/v1/funding/providers']!.get!), quotes: summarize(spec.paths['/v1/funding/quotes']!.get!), sourceTransaction: summarize(spec.paths[sourceTransactionPath]!.post!), vault: summarize(spec.paths['/v1/funding/transfers/vault']!.post!), zone: summarize(spec.paths['/v1/funding/transfers/zone']!.post!), } const category = spec['x-tagGroups'].find((group) => group.name === 'Funding & Bridge API') const categoryTags = new Set(category?.tags) const sidebar = Object.values(spec.paths).flatMap((path) => [path.get, path.post].flatMap((operation) => operation?.tags.some((tag) => categoryTags.has(tag)) ? [operation.summary] : [], ), ) for (const operation of Object.values(operations)) expect(operation.description.split(/\s+/).length).toBeLessThan(15) expect({ category: category?.name, operations, sidebar, }).toMatchInlineSnapshot(` { "category": "Funding & Bridge API", "operations": { "account": { "description": "Creates a transfer to a Tempo account.", "summary": "Create transfer", }, "chains": { "description": "Lists supported source chains and tokens.", "summary": "Get chains", }, "deposit": { "description": "Returns one detected deposit and its delivery status.", "summary": "Get deposit", }, "depositAddress": { "description": "Returns one reusable funding deposit address.", "summary": "Get deposit address", }, "depositAddressCreate": { "description": "Creates or returns a reusable deposit address for funding a Tempo account.", "summary": "Create deposit address", }, "depositAddressList": { "description": "Lists reusable funding deposit addresses, newest first.", "summary": "List deposit addresses", }, "depositAddressReconcile": { "description": "Queues an immediate provider reconciliation for one reusable funding deposit address.", "summary": "Reconcile deposit address", }, "deposits": { "description": "Lists visible deposits, optionally filtered, newest first.", "summary": "List deposits", }, "detail": { "description": "Returns a transfer by ID.", "summary": "Get transfer", }, "list": { "description": "Lists transfers from newest to oldest.", "summary": "List transfers", }, "providers": { "description": "Lists available transfer providers.", "summary": "Get providers", }, "quotes": { "description": "Returns live quotes for transferring stablecoins to Tempo.", "summary": "Get quotes", }, "sourceTransaction": { "description": "Registers and verifies a funding source transaction.", "summary": "Register source transaction", }, "vault": { "description": "Creates a transfer into a Tempo Earn vault.", "summary": "Create transfer into vault", }, "zone": { "description": "Creates a transfer into a Tempo Zone.", "summary": "Create transfer into zone", }, }, "sidebar": [ "Get quotes", "Get chains", "Get providers", "List deposit addresses", "Create deposit address", "Register source transaction", "List transfers", "Create transfer", "Create transfer into vault", "Create transfer into zone", "Reconcile deposit address", "Get deposit address", "Get transfer", "Get deposit", "List deposits", ], } `) }) test('publishes concrete creation schemas', async () => { const app = create({ auth: false }) type Spec = ComponentSpec & { paths: Record< string, { post?: { requestBody: { content: Record } responses: Record } } > } const spec = (await (await app.request('/openapi.json')).json()) as Spec const creationSchema = (path: string) => dereference(spec, spec.paths[path]!.post!.requestBody.content['application/json']!.schema) expect( creationSchema('/v1/funding/deposit-addresses').required?.includes('subsidize') ?? false, ).toMatchInlineSnapshot(`false`) const summarize = (path: string) => { const schema = creationSchema(path) return { additionalProperties: schema.additionalProperties, properties: Object.keys(schema.properties!), } } expect({ account: summarize('/v1/funding/transfers'), depositAddress: summarize('/v1/funding/deposit-addresses'), depositAddressResponses: Object.keys( spec.paths['/v1/funding/deposit-addresses']!.post!.responses, ), vault: summarize('/v1/funding/transfers/vault'), vaultResponses: Object.keys(spec.paths['/v1/funding/transfers/vault']!.post!.responses), zone: summarize('/v1/funding/transfers/zone'), zoneResponses: Object.keys(spec.paths['/v1/funding/transfers/zone']!.post!.responses), }).toMatchInlineSnapshot(` { "account": { "additionalProperties": false, "properties": [ "amount", "destinationToken", "mode", "provider", "recipient", "sender", "slippageBps", "sourceChain", "sourceToken", ], }, "depositAddress": { "additionalProperties": false, "properties": [ "amount", "destinationToken", "recipient", "refundAddress", "sourceChain", "sourceToken", "subsidize", ], }, "depositAddressResponses": [ "200", "400", "401", "403", "404", "409", "429", "500", "502", "504", ], "vault": { "additionalProperties": false, "properties": [ "amount", "mode", "recipient", "sender", "slippageBps", "sourceChain", "sourceToken", "vaultAddress", ], }, "vaultResponses": [ "400", "401", "403", "429", "500", "501", "504", ], "zone": { "additionalProperties": false, "properties": [ "amount", "destinationChain", "destinationToken", "mode", "recipient", "recipientFallback", "sender", "slippageBps", "sourceChain", "sourceToken", ], }, "zoneResponses": [ "400", "401", "403", "429", "500", "501", "504", ], } `) }) test('publishes alphabetically ordered example fields and parameter examples', async () => { const app = create({ auth: false }) const spec = (await (await app.request('/openapi.json')).json()) as { paths: Record< string, { get: { parameters?: { example?: unknown; name: string; schema?: { examples?: unknown } }[] responses: Record }> } } > } const operation = spec.paths['/v1/funding/transfers']!.get const fields = Object.keys(operation.responses['200']!.content['application/json']!.example.data[0]!) // prettier-ignore expect(fields).toEqual([...fields].sort()) for (const route of [ operation, spec.paths['/v1/funding/deposit-addresses']!.get, spec.paths['/v1/funding/deposits']!.get, ]) for (const parameter of route.parameters ?? []) expect(parameter.example ?? parameter.schema?.examples, parameter.name).toBeDefined() }) test('publishes operation ids and required scopes', async () => { const app = create({ auth: false }) const spec = (await (await app.request('/openapi.json')).json()) as { paths: Record< string, { get: { operationId: string; 'x-required-scopes': string[] } post?: { operationId: string parameters: { in: string; name: string; required: boolean }[] 'x-required-scopes': string[] } } > } const detailPath = Object.keys(spec.paths).find( (path) => path.includes('/v1/funding/transfers/{') && !path.endsWith('/source-transactions'), ) const sourceTransactionPath = Object.keys(spec.paths).find((path) => path.endsWith('/source-transactions'), ) const depositAddressPath = Object.keys(spec.paths).find( (path) => path.includes('/v1/funding/deposit-addresses/{') && !path.endsWith('/deposits') && !path.endsWith('/reconcile'), ) const depositPath = Object.keys(spec.paths).find((path) => path.includes('/v1/funding/deposits/{'), ) const reconcilePath = Object.keys(spec.paths).find((path) => path.endsWith('/reconcile')) expect({ deposit: depositPath && spec.paths[depositPath]?.get.operationId, depositAddress: depositAddressPath && spec.paths[depositAddressPath]?.get.operationId, depositAddressCreate: spec.paths['/v1/funding/deposit-addresses']?.post?.operationId, depositAddressCreateScopes: spec.paths['/v1/funding/deposit-addresses']?.post?.['x-required-scopes'], depositAddressList: spec.paths['/v1/funding/deposit-addresses']?.get.operationId, depositAddressListScopes: spec.paths['/v1/funding/deposit-addresses']?.get['x-required-scopes'], depositAddressReconcile: reconcilePath && spec.paths[reconcilePath]?.post?.operationId, depositAddressReconcileScopes: reconcilePath && spec.paths[reconcilePath]?.post?.['x-required-scopes'], depositAddressScopes: depositAddressPath && spec.paths[depositAddressPath]?.get['x-required-scopes'], depositScopes: depositPath && spec.paths[depositPath]?.get['x-required-scopes'], deposits: spec.paths['/v1/funding/deposits']?.get.operationId, depositsScopes: spec.paths['/v1/funding/deposits']?.get['x-required-scopes'], detail: detailPath && spec.paths[detailPath]?.get.operationId, detailScopes: detailPath && spec.paths[detailPath]?.get['x-required-scopes'], list: spec.paths['/v1/funding/transfers']?.get.operationId, listScopes: spec.paths['/v1/funding/transfers']?.get['x-required-scopes'], requiredCreateHeader: spec.paths['/v1/funding/transfers']?.post?.parameters.find( (parameter) => parameter.name === 'idempotency-key', ), sourceTransaction: sourceTransactionPath && spec.paths[sourceTransactionPath]?.post?.operationId, sourceTransactionScopes: sourceTransactionPath && spec.paths[sourceTransactionPath]?.post?.['x-required-scopes'], vault: spec.paths['/v1/funding/transfers/vault']?.post?.operationId, vaultScopes: spec.paths['/v1/funding/transfers/vault']?.post?.['x-required-scopes'], zone: spec.paths['/v1/funding/transfers/zone']?.post?.operationId, zoneScopes: spec.paths['/v1/funding/transfers/zone']?.post?.['x-required-scopes'], }).toEqual({ deposit: 'getFundingDeposit', depositAddress: 'getFundingDepositAddress', depositAddressCreate: 'createFundingDepositAddress', depositAddressCreateScopes: ['funding:write'], depositAddressList: 'listFundingDepositAddresses', depositAddressListScopes: ['funding:read'], depositAddressReconcile: 'reconcileFundingDepositAddress', depositAddressReconcileScopes: ['funding:write'], depositAddressScopes: ['funding:read'], depositScopes: ['funding:read'], deposits: 'listFundingDeposits', depositsScopes: ['funding:read'], detail: 'getFundingTransfer', detailScopes: ['funding:read'], list: 'listFundingTransfers', listScopes: ['funding:read'], requiredCreateHeader: expect.objectContaining({ description: expect.stringContaining('24 hours'), in: 'header', name: 'idempotency-key', required: true, schema: expect.any(Object), }), sourceTransaction: 'registerFundingTransferSourceTransaction', sourceTransactionScopes: ['funding:write'], vault: 'createFundingTransferVault', vaultScopes: ['funding:write'], zone: 'createFundingTransferZone', zoneScopes: ['funding:write'], }) }) }) const fundingReader = { id: 'key_funding', orgId: 'org_funding', scopes: ['funding:read'], token: 'secret_funding_reader', } satisfies TestApp.kvStore.Key const fundingProjectReader = { id: 'key_funding_project', orgId: 'org_funding', projectId: 'prj_funding', scopes: ['funding:read'], token: 'secret_funding_project', } satisfies TestApp.kvStore.Key const fundingForeign = { id: 'key_funding_other', orgId: 'org_funding_other', scopes: ['funding:read', 'funding:write'], token: 'secret_funding_other', } satisfies TestApp.kvStore.Key const fundingWriter = { id: 'key_funding_writer', orgId: 'org_funding', scopes: ['funding:read', 'funding:write'], token: 'secret_funding_writer', } satisfies TestApp.kvStore.Key function transfersApp(options: TestApp.create.Options = {}) { return create({ ...options, auth: { keys: [TestApp.key, fundingReader, fundingProjectReader, fundingForeign, fundingWriter], }, defaultChainId: options.defaultChainId ?? tempoMainnet.id, }) } function as(key: { token: string }) { return { headers: { authorization: `Bearer ${key.token}` } } as const } let seedSecond = 0 function seedTransfer(input: Partial = {}) { // Distinct create times keep ids strictly ordered for paging assertions. return FundingTransfer.create(db, { apiKeyId: fundingReader.id, environment: 'production', id: FundingTransfer.generateId(new Date(Date.UTC(2026, 0, 2, 0, 0, seedSecond++))), orgId: fundingReader.orgId, snapshot: TestFunding.transferSnapshot(), ...input, }) }