import { type Context, Hono } from 'hono' import { etag } from 'hono/etag' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Db from '../../../db/Db.js' import * as FundingIdempotency from '../../../db/tables/fundingIdempotency.js' import * as FundingTransfers from '../../../db/tables/fundingTransfers.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as FundingCatalog from '../../../internal/funding/Catalog.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 OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Value from '../../../internal/Value.js' import type * as Webhooks from '../../../internal/Webhooks.js' import * as Access from '../Access.js' import * as ProviderAttempts from '../ProviderAttempts.js' /** Zod schemas owned by funding transfer resources. */ export namespace schema { /** A durable funding transfer. Reads never include the executable `action`. */ export const Transfer = FundingTransfer.schema.FundingTransfer /** Schemas for the listFundingTransfers operation. */ export namespace listFundingTransfers { /** Query parameters for listing funding transfers. */ export const Query = z .object({ cursor: Schema.Cursor, include: Schema.totalCountInclude, limit: Schema.Limit, status: z.optional(FundingTransfer.schema.Status), }) .check(z.describe('Query parameters for listing funding transfers.')) /** Paginated funding transfer list. */ export const Response = OpenApi.component( z .object({ data: z .array(Transfer) .check(z.describe('Funding transfers on this page, newest first.')), meta: z .optional(Schema.CountMeta) .check(z.describe('Extra response metadata requested with `include`.')), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of funding transfers.')), 'FundingTransferList', ) } /** Schemas for the createFundingTransfer operation. */ export namespace createFundingTransfer { /** Funding transfer creation request body. */ export const Body = FundingTransfer.schema.CreateRequest /** Required headers for funding transfer creation. */ export const Headers = z.object({ 'idempotency-key': z .string() .check( z.minLength(1), z.describe( 'Opaque retry key scoped to the API key. Matching requests replay successful responses for 24 hours; changed input conflicts, in-flight requests block, and failed attempts release the key.', ), z.meta({ examples: ['funding_01k1c5j8q8p0be6j5v9m6d1e4r'] }), ), }) /** The created transfer with its one-time executable action. */ export const Response = FundingTransfer.schema.CreatedFundingTransfer } const destinationCreateFields = { amount: FundingTransfer.schema.CreateRequest.shape.amount, mode: FundingTransfer.schema.CreateRequest.shape.mode, recipient: FundingTransfer.schema.CreateRequest.shape.recipient, sender: FundingTransfer.schema.CreateRequest.shape.sender, slippageBps: FundingTransfer.schema.CreateRequest.shape.slippageBps, sourceChain: FundingTransfer.schema.CreateRequest.shape.sourceChain, sourceToken: FundingTransfer.schema.CreateRequest.shape.sourceToken, } /** Schemas for the createFundingTransferVault operation. */ export namespace createFundingTransferVault { /** Funding transfer creation request for an Earn vault destination. */ export const Body = OpenApi.component( z .strictObject({ ...destinationCreateFields, vaultAddress: z .string() .check( z.regex(/^0x[0-9a-fA-F]{40}$/), z.describe('Tempo Earn vault contract address.'), z.meta({ examples: [`0x${'f4'.repeat(20)}`] }), ), }) .check(z.describe('Vault funding transfer creation request.')), 'CreateFundingTransferVaultRequest', ) /** Required headers for vault funding transfer creation. */ export const Headers = createFundingTransfer.Headers } /** Schemas for the createFundingTransferZone operation. */ export namespace createFundingTransferZone { /** Funding transfer creation request for a Zone destination. */ export const Body = OpenApi.component( z .strictObject({ amount: destinationCreateFields.amount, destinationChain: z .string() .check( z.regex(/^eip155:\d+$/), z.describe('Configured Zone CAIP-2 id.'), z.meta({ examples: ['eip155:1424310003'] }), ), destinationToken: FundingTransfer.schema.CreateRequest.shape.destinationToken, mode: destinationCreateFields.mode, recipient: destinationCreateFields.recipient, recipientFallback: z .string() .check( z.regex(/^0x[0-9a-fA-F]{40}$/), z.describe('Public Tempo recipient used if the Zone credit cannot complete.'), z.meta({ examples: [`0x${'22'.repeat(20)}`] }), ), sender: destinationCreateFields.sender, slippageBps: destinationCreateFields.slippageBps, sourceChain: destinationCreateFields.sourceChain, sourceToken: destinationCreateFields.sourceToken, }) .check(z.describe('Zone funding transfer creation request.')), 'CreateFundingTransferZoneRequest', ) /** Required headers for Zone funding transfer creation. */ export const Headers = createFundingTransfer.Headers } /** Schemas for the getFundingTransfer operation. */ export namespace getFundingTransfer { /** Path parameters for a funding transfer read. */ export const Params = z .object({ id: z .string() .check( z.describe('Funding transfer ID (`ftr_…`).'), z.meta({ examples: ['ftr_001785729600000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), }) .check(z.describe('Path parameters for a funding transfer request.')) /** The funding transfer resource, returned directly. */ export const Response = Transfer } /** Schemas for the registerFundingTransferSourceTransaction operation. */ export namespace registerFundingTransferSourceTransaction { /** Source transaction registration request body. */ export const Body = OpenApi.component( z .strictObject({ transactionHash: Schema.hash(`0x${'51'.repeat(32)}`), }) .check(z.describe('Verified source transaction to associate with the transfer.')), 'RegisterFundingTransferSourceTransactionRequest', ) /** Path parameters for source transaction registration. */ export const Params = getFundingTransfer.Params /** The transfer after source transaction registration. */ export const Response = Transfer } } const baseChainExample = { addressFormat: 'hex', id: 'eip155:8453', kind: 'evm', name: 'Base', } as const const baseUsdcExample = { address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', currency: 'USD', decimals: 6, name: 'USD Coin', standard: 'ERC-20', symbol: 'USDC', tokenKey: 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', verified: true, } as const const fundingTransferExample = { createdAt: '2026-01-01T00:00:00.000Z', destinationAmount: Value.tokenAmount({ baseUnits: '979295', currency: 'USD', decimals: 6 }), destinationAmountMin: Value.tokenAmount({ baseUnits: '939242', currency: 'USD', decimals: 6 }), destinationChain: { addressFormat: 'hex', id: 'eip155:4217', kind: 'evm', name: 'Tempo', }, destinationToken: { address: '0x20c0000000000000000000000000000000000000', currency: 'USD', decimals: 6, name: 'PathUSD', standard: 'TIP-20', symbol: 'pathUSD', tokenKey: 'eip155:4217/erc20:0x20c0000000000000000000000000000000000000', verified: true, }, fees: [ { amount: Value.tokenAmount({ baseUnits: '20705', currency: 'USD', decimals: 6 }), side: 'source', token: baseUsdcExample, type: 'provider', }, ], id: 'ftr_001767225600000_2ZPE2gvateYEQ0dQslgvkhjx', method: 'transaction', mode: 'exactSource', provider: { id: 'relay', name: 'Relay' }, quote: { expiresAt: '2026-01-01T00:01:00.000Z', sampledAt: '2026-01-01T00:00:00.000Z', }, recipient: `0x${'11'.repeat(20)}`, sender: `0x${'22'.repeat(20)}`, sourceAmount: Value.tokenAmount({ baseUnits: '1000000', currency: 'USD', decimals: 6 }), sourceChain: baseChainExample, sourceToken: baseUsdcExample, status: 'awaiting-source', updatedAt: '2026-01-01T00:00:00.000Z', version: 1, } satisfies z.output const registeredFundingTransferExample = { ...fundingTransferExample, sourceTransactionHashes: [`0x${'51'.repeat(32)}`], status: 'processing', updatedAt: '2026-01-01T00:00:20.000Z', version: 2, } satisfies z.output const listFundingTransfersResponseExample = { data: [fundingTransferExample], nextCursor: null, } satisfies z.output const createdFundingTransferExample = { action: { calls: [ { data: `0x095ea7b3000000000000000000000000${'44'.repeat(20)}00000000000000000000000000000000000000000000000000000000000f4240`, to: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', value: '0x0', }, { data: '0xe8017952', to: `0x${'44'.repeat(20)}`, value: '0x0', }, ], type: 'evm:calls', }, ...fundingTransferExample, } satisfies z.output const unimplementedFundingTransferResponses = (() => { const responses = OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'idempotency_key_required'] }, 501: { codes: ['not_implemented'], description: 'This funding transfer destination is not implemented.', }, }, success: { description: 'Unused until this destination is implemented.', schema: z.never(), }, }) const { 200: success, 502: upstream, ...errors } = responses void success void upstream return errors })() // Replay the exact successful creation response for 24 hours. const idempotencyReplayTtlMs = 86_400_000 // Pending claims only cover the bounded provider preparation window. const idempotencyPendingTtlMs = 60_000 const fundingEtag = etag() const bodyValidation = { code: 'body_invalid', message: 'Check the request body and try again.', } as const const paramValidation = { code: 'param_invalid', message: 'Check the path parameters and try again.', } as const /** Mounts durable funding transfer routes. */ export function transfers(options: transfers.Options = {}) { return new Hono() .get( '/v1/funding/transfers', Auth.policy({ apiKey: { scopes: ['funding:read'] } }), OpenApi.validate('query', schema.listFundingTransfers.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ description: 'Lists transfers from newest to oldest.', operationId: 'listFundingTransfers', responses: OpenApi.responses({ errors: { 400: { codes: ['query_invalid'] }, }, success: { description: 'A page of funding transfers.', example: listFundingTransfersResponseExample, schema: schema.listFundingTransfers.Response, }, }), summary: 'List transfers', tags: ['Funding Transfers'], }), fundingEtag, async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const owner = Access.owner(c) if (!owner) return Access.unauthorized(c) const db = Db.get(c.get('db')) const { cursor, include, limit, status } = c.req.valid('query') try { // `totalCount` is opt-in and capped (a capped subquery shares the // list filter); run it concurrently with the page list. Best-effort: // a failure omits `meta` rather than failing the page. const countPromise = include.includes('totalCount') ? FundingTransfers.count(db, owner, { cap: Schema.countCap, status }).catch(() => { c.set('fundingTransferCountFailed', true) return undefined }) : undefined // Fetch one extra to detect a further page without a second round-trip. const rows = await FundingTransfers.list(db, owner, { cursor, limit: limit + 1, status }) const hasMore = rows.length > limit const data = (hasMore ? rows.slice(0, limit) : rows).map(FundingTransfer.toPublic) const nextCursor = hasMore ? (data[data.length - 1]?.id ?? null) : null const count = countPromise ? await countPromise : undefined c.header('Cache-Control', Cache.policies.feed) c.header('Vary', 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key') return c.json( Response.validated(schema.listFundingTransfers.Response, { data, ...(count !== undefined ? { meta: { totalCountCapped: count > Schema.countCap, totalCount: Math.min(count, Schema.countCap), }, } : {}), nextCursor, }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/funding/transfers/:id{ftr_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['funding:read'] } }), OpenApi.validate('param', schema.getFundingTransfer.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: 'Returns a transfer by ID.', operationId: 'getFundingTransfer', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'] }, 404: { codes: ['funding_transfer_not_found'], description: 'No visible funding transfer exists for the identifier.', }, }, success: { description: 'The requested funding transfer.', example: fundingTransferExample, schema: schema.getFundingTransfer.Response, }, }), summary: 'Get transfer', tags: ['Funding Transfers'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const owner = Access.owner(c) if (!owner) return Access.unauthorized(c) const db = Db.get(c.get('db')) const { id } = c.req.valid('param') try { // Ownership filters in SQL, so absent and foreign rows are the same 404. const record = await FundingTransfers.getOwned(db, owner, id) if (!record) return Response.error(c, { code: 'funding_transfer_not_found', message: 'Funding transfer not found', status: 404, }) c.header('Cache-Control', Cache.policies.noStore) c.header('Vary', 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key') return c.json( Response.validated( schema.getFundingTransfer.Response, FundingTransfer.toPublic(record), ), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/funding/transfers/:id{ftr_[A-Za-z0-9_-]+}/source-transactions', Auth.policy({ apiKey: { scopes: ['funding:write'] } }), OpenApi.validate( 'param', schema.registerFundingTransferSourceTransaction.Params, paramValidation, ), OpenApi.validate( 'json', schema.registerFundingTransferSourceTransaction.Body, bodyValidation, ), OpenApi.describeRoute({ description: 'Registers and verifies a funding source transaction.', operationId: 'registerFundingTransferSourceTransaction', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'param_invalid', 'source_transaction_invalid'], description: 'The path, body, or submitted source transaction is invalid.', }, 404: { codes: ['funding_transfer_not_found'], description: 'No visible funding transfer exists for the identifier.', }, 409: { codes: [ 'funding_transfer_not_awaiting_source', 'source_transaction_conflict', 'source_transaction_pending', ], description: 'The transfer or source transaction cannot be registered yet.', }, 501: { codes: ['not_implemented'], description: 'Source transaction reconciliation is not configured.', }, 502: { codes: ['upstream_error'], description: 'Source verification or reconciliation dispatch failed.', }, }, success: { description: 'The transfer with its verified source transaction.', example: registeredFundingTransferExample, schema: schema.registerFundingTransferSourceTransaction.Response, }, }), summary: 'Register source transaction', tags: ['Funding Transfers'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation) const owner = Access.owner(c) if (!owner) return Access.unauthorized(c) c.header('Cache-Control', Cache.policies.noStore) c.header('Vary', 'Accept-Encoding, Authorization, Tempo-API-Key, X-API-Key') const dispatch = options.dispatchTransferReconciliation if (!dispatch) return Response.error(c, { code: 'not_implemented', message: 'Source transaction reconciliation is not configured', status: 501, }) const db = Db.get(c.get('db')) const { id } = c.req.valid('param') const { transactionHash } = c.req.valid('json') try { const record = await FundingTransfers.getOwned(db, owner, id) if (!record) return Response.error(c, { code: 'funding_transfer_not_found', message: 'Funding transfer not found', status: 404, }) if ( record.snapshot.sourceTransactionHashes?.some( (candidate) => candidate.toLowerCase() === transactionHash, ) ) { if (record.status === 'processing') await dispatch({ transferId: record.id, type: 'funding:transfer:reconcile' }) return c.json( Response.validated( schema.registerFundingTransferSourceTransaction.Response, FundingTransfer.toPublic(record), ), 200, ) } const provider = c .get('providers') .filter(FundingProvider.is) .find((candidate) => candidate.id === record.providerId) if (!provider || !FundingProvider.canVerifySourceTransaction(provider)) return Response.error(c, { code: 'funding_transfer_not_awaiting_source', message: 'Funding transfer does not accept source transactions', status: 409, }) if (record.status !== 'awaiting-source') return Response.error(c, { code: 'funding_transfer_not_awaiting_source', message: 'Funding transfer is not awaiting a source transaction', status: 409, }) const catalog = await FundingCatalog.snapshot(c) const destinationChain = catalog.chainsByKey.get(record.snapshot.destinationChain.id) const sourceChain = catalog.chainsByKey.get(record.snapshot.sourceChain.id) const routeConfiguration = catalog.routesByProvider .get(record.providerId) ?.find( (candidate) => candidate.route.source.chain.id === record.snapshot.sourceChain.id && candidate.route.source.address.toLowerCase() === record.snapshot.sourceToken.address.toLowerCase() && candidate.route.destination.chain.id === record.snapshot.destinationChain.id && candidate.route.destination.address.toLowerCase() === record.snapshot.destinationToken.address.toLowerCase(), )?.configuration if (!destinationChain || !sourceChain) return Response.error(c, { code: 'funding_transfer_not_awaiting_source', message: 'Funding transfer route chains are no longer configured', status: 409, }) const evidence = await provider.verifySourceTransaction( { destinationChain, providerState: record.providerState ?? undefined, routeConfiguration, sourceChain, transactionHash, transfer: record.snapshot, }, c.req.raw.signal, ) if (evidence.type === 'pending') return Response.error(c, { code: 'source_transaction_pending', message: 'Source transaction is not confirmed yet', status: 409, }) if (evidence.type === 'invalid') return Response.error(c, { code: 'source_transaction_invalid', message: 'Source transaction does not match the funding transfer', status: 400, }) const registered = await FundingTransfer.registerSourceTransaction(db, { chainId: record.snapshot.sourceChain.id, id: record.id, providerState: evidence.providerState ?? {}, transactionHash, }) if (registered.type === 'not_found') return Response.error(c, { code: 'funding_transfer_not_found', message: 'Funding transfer not found', status: 404, }) if (registered.type === 'conflict') return Response.error(c, { code: 'source_transaction_conflict', message: 'Source transaction is already assigned to another funding transfer', status: 409, }) if (registered.type === 'invalid') return Response.error(c, { code: 'funding_transfer_not_awaiting_source', message: 'Funding transfer is not awaiting a source transaction', status: 409, }) await Promise.all([ dispatch({ transferId: record.id, type: 'funding:transfer:reconcile' }), registered.type === 'registered' && options.dispatchTransferUpdates && registered.references.length > 0 ? options.dispatchTransferUpdates(registered.references) : undefined, ]) return c.json( Response.validated( schema.registerFundingTransferSourceTransaction.Response, FundingTransfer.toPublic(registered.record), ), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/funding/transfers', Auth.policy({ apiKey: { scopes: ['funding:write'] } }), OpenApi.validate('header', schema.createFundingTransfer.Headers, { code: 'idempotency_key_required', message: 'Funding transfer creation requires an `Idempotency-Key` header.', }), OpenApi.validate('json', schema.createFundingTransfer.Body, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ description: 'Creates a transfer to a Tempo account.', operationId: 'createFundingTransfer', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'idempotency_key_required'] }, 404: { codes: ['quote_not_available'], description: 'No provider can prepare the requested route.', }, 409: { codes: ['idempotency_conflict', 'idempotency_in_progress'], description: 'The Idempotency-Key conflicts with an existing or in-flight request.', }, 502: { codes: ['upstream_error'], description: 'No provider succeeded and at least one failed unexpectedly.', }, }, success: { description: 'The created funding transfer with its one-time executable action.', example: createdFundingTransferExample, schema: schema.createFundingTransfer.Response, }, }), summary: 'Create transfer', tags: ['Funding Transfers'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'body_invalid', message: 'Check the request body and try again.', }) const principal = Auth.getPrincipal(c) const owner = Access.owner(c) if (!owner || principal?.type !== 'api_key') return Access.unauthorized(c) c.header('Cache-Control', 'no-store') const idempotencyKey = c.req.valid('header')['idempotency-key'] const body = c.req.valid('json') const db = Db.get(c.get('db')) const identity = { apiKeyId: principal.id, keyHash: Idempotency.inputHash(idempotencyKey), requestHash: Idempotency.inputHash(body), } const claim = await FundingIdempotency.claim(db, { ...identity, ttlMs: idempotencyPendingTtlMs, }) if (claim.type === 'mismatch') return Response.error(c, { code: 'idempotency_conflict', message: 'This Idempotency-Key was already used with different input.', status: 409, }) if (claim.type === 'pending') return Response.error(c, { code: 'idempotency_in_progress', message: 'An equivalent funding transfer creation is still running.', status: 409, }) if (claim.type === 'replay') return c.json( JSON.parse(claim.response) as z.output, 200, ) const release = async () => { try { await FundingIdempotency.release(db, { apiKeyId: identity.apiKeyId, createdAt: claim.createdAt, keyHash: identity.keyHash, }) } catch { // The pending claim expires on its own; the response stays authoritative. } } try { const attempts: FundingProvider.ProviderAttempt[] = [] const catalog = await FundingCatalog.snapshot(c) const providers = c.get('providers').filter(FundingProvider.is) const selected = await FundingProvider.prepareTransfer({ catalog, destinationChainId: c.get('chainId'), destinationToken: body.destinationToken, method: 'transaction', mode: body.mode, onAttempt: (attempt) => attempts.push(attempt), provider: body.provider, providers, recipient: body.recipient, sender: body.sender, slippageBps: body.slippageBps, sourceAmount: body.amount, sourceChain: body.sourceChain, sourceToken: body.sourceToken, }) if (attempts.length > 0) c.set('fundingProviderAttempts', attempts.map(ProviderAttempts.format)) if (!selected) { await release() return quoteNotAvailable(c) } const { candidate, result } = selected const snapshot: FundingTransfer.Snapshot = { destinationAmount: Value.tokenAmount({ baseUnits: result.destinationAmount, currency: candidate.destinationToken.currency, decimals: candidate.destinationToken.decimals, }), destinationAmountMin: Value.tokenAmount({ baseUnits: result.destinationAmountMin, currency: candidate.destinationToken.currency, decimals: candidate.destinationToken.decimals, }), destinationChain: candidate.destinationChain, destinationToken: candidate.destinationToken, fees: result.fees.map((fee) => { const token = fee.token ?? (fee.side === 'source' ? candidate.sourceToken : candidate.destinationToken) return { amount: Value.tokenAmount({ baseUnits: fee.amount, currency: token.currency, decimals: token.decimals, }), side: fee.side, token, type: 'provider', } }), method: 'transaction', mode: body.mode, provider: candidate.provider, quote: { expiresAt: result.expiresAt, sampledAt: result.sampledAt }, recipient: body.recipient, sender: body.sender, sourceAmount: Value.tokenAmount({ baseUnits: candidate.sourceAmount.amount, currency: candidate.sourceToken.currency, decimals: candidate.sourceToken.decimals, }), sourceChain: candidate.sourceChain, sourceToken: candidate.sourceToken, } // The transfer row, its version-1 event, and the replay body commit // atomically so a crash cannot leave a claimed key without a replay. const created = await db.transaction(async (tx) => { const { record, references } = await FundingTransfer.createIn(tx, { apiKeyId: principal.id, environment: owner.environment, orgId: owner.orgId, projectId: owner.projectId, providerState: result.correlation || result.providerState ? { ...result.correlation, ...result.providerState, } : undefined, snapshot, }) const validated = Response.validated( schema.createFundingTransfer.Response, FundingTransfer.toCreated(record, result.action), ) const completion = await FundingIdempotency.complete(tx, { apiKeyId: identity.apiKeyId, createdAt: claim.createdAt, keyHash: identity.keyHash, replayTtlMs: idempotencyReplayTtlMs, response: JSON.stringify(validated), transferId: record.id, }) if (!completion) throw new IdempotencyLeaseLostError('Funding transfer creation lease was lost.') return { references, response: validated } }) if (options.dispatchTransferUpdates && created.references.length > 0) try { await options.dispatchTransferUpdates(created.references) } catch (cause) { return Response.upstream(c, cause) } return c.json(created.response, 200) } catch (cause) { await release() if (cause instanceof FundingProvider.InvalidAmountError) return Response.error(c, { code: 'body_invalid', message: 'Invalid `amount`.', status: 400 }) // prettier-ignore if (cause instanceof FundingProvider.UnsupportedProviderError) return Response.error(c, { code: 'body_invalid', message: 'Unsupported `provider`.', status: 400 }) // prettier-ignore if (cause instanceof FundingProvider.UnsupportedSourceChainError) return quoteNotAvailable(c) if (cause instanceof FundingProvider.ProviderQuoteError) { if (cause.fundingProviderAttempts.length > 0) c.set('fundingProviderAttempts', cause.fundingProviderAttempts) if (cause.providerFailure) c.set('providerFailure', cause.providerFailure) if (cause.providerFailures) c.set('providerFailures', cause.providerFailures) return Response.error(c, { code: 'upstream_error', message: 'No funding provider could prepare the transfer.', status: 502, }) } throw cause } }, ) .post( '/v1/funding/transfers/vault', Auth.policy({ apiKey: { scopes: ['funding:write'] } }), OpenApi.validate('header', schema.createFundingTransferVault.Headers, { code: 'idempotency_key_required', message: 'Funding transfer creation requires an `Idempotency-Key` header.', }), OpenApi.validate('json', schema.createFundingTransferVault.Body, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ description: 'Creates a transfer into a Tempo Earn vault.', operationId: 'createFundingTransferVault', responses: unimplementedFundingTransferResponses, summary: 'Create transfer into vault', tags: ['Funding Transfers'], }), (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'body_invalid', message: 'Check the request body and try again.', }) c.header('Cache-Control', 'no-store') return Response.error(c, { code: 'not_implemented', message: 'Vault funding transfers are not implemented.', status: 501, }) }, ) .post( '/v1/funding/transfers/zone', Auth.policy({ apiKey: { scopes: ['funding:write'] } }), OpenApi.validate('header', schema.createFundingTransferZone.Headers, { code: 'idempotency_key_required', message: 'Funding transfer creation requires an `Idempotency-Key` header.', }), OpenApi.validate('json', schema.createFundingTransferZone.Body, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ description: 'Creates a transfer into a Tempo Zone.', operationId: 'createFundingTransferZone', responses: unimplementedFundingTransferResponses, summary: 'Create transfer into zone', tags: ['Funding Transfers'], }), (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'body_invalid', message: 'Check the request body and try again.', }) c.header('Cache-Control', 'no-store') return Response.error(c, { code: 'not_implemented', message: 'Zone funding transfers are not implemented.', status: 501, }) }, ) } export declare namespace transfers { /** Funding transfer route dependencies. */ type Options = { /** Enqueues one registered transfer for destination reconciliation. */ dispatchTransferReconciliation?: | ((message: FundingTransferReconciliation.Message) => Promise) | undefined /** Enqueues webhook updates staged with transfer mutations. */ dispatchTransferUpdates?: | ((references: readonly Webhooks.QueueReference[]) => Promise) | undefined } } function quoteNotAvailable(c: Context) { return Response.error(c, { code: 'quote_not_available', message: 'No provider can prepare the requested funding route.', status: 404, }) } class IdempotencyLeaseLostError extends Error { override name = 'Funding.IdempotencyLeaseLostError' }