import * as z from 'zod/mini' import type * as Db from '../../db/Db.js' import * as FundingTransferEvents from '../../db/tables/fundingTransferEvents.js' import * as FundingTransferTransactions from '../../db/tables/fundingTransferTransactions.js' import * as FundingTransfers from '../../db/tables/fundingTransfers.js' import * as Id from '../Id.js' import * as OpenApi from '../OpenApi.js' import * as Schema from '../Schema.js' import type * as Webhooks from '../Webhooks.js' import * as Action from './Action.js' import * as Provider from './Provider.js' import * as TransferWebhook from './TransferWebhook.js' /** Public funding transfer statuses, in the order documented by the API. */ export const statuses = [ 'awaiting-source', 'processing', 'refunding', 'completed', 'refunded', 'action-required', 'expired', ] as const /** Lifecycle status of a funding transfer. */ export type Status = (typeof statuses)[number] /** Statuses that never change again after verified chain evidence. */ export const terminalStatuses = ['completed', 'expired', 'refunded'] as const satisfies readonly Status[] // prettier-ignore /** Funding methods a caller can select at creation. */ export const methods = ['depositAddress', 'transaction'] as const /** Funding method selected at creation. */ export type Method = (typeof methods)[number] /** Amount modes a caller can select at creation. */ export const modes = ['exactDestination', 'exactSource'] as const /** Amount mode selected at creation. */ export type Mode = (typeof modes)[number] /** Customer-safe codes explaining an `action-required` or refund outcome. */ export const statusReasonCodes = [ 'destination_amount_below_minimum', 'destination_recovered', 'manual_recovery_required', 'refund_failed', 'source_amount_above_maximum', 'source_amount_below_minimum', 'source_chain_mismatch', 'source_token_mismatch', ] as const /** Stable customer-safe status reason code. */ export type StatusReasonCode = (typeof statusReasonCodes)[number] // The public state chart has no exits from `action-required`, but late chain // evidence or operator-triggered reconciliation may still resolve one, so // evidence-driven exits stay allowed. const transitions: Record = { 'action-required': ['completed', 'processing', 'refunded', 'refunding'], 'awaiting-source': ['action-required', 'expired', 'processing'], completed: [], expired: [], processing: ['action-required', 'awaiting-source', 'completed', 'refunding'], refunded: [], refunding: ['action-required', 'refunded'], } /** Returns whether a status is terminal. */ export function isTerminal(status: Status): boolean { return (terminalStatuses as readonly Status[]).includes(status) } /** Returns whether the lifecycle allows moving from one status to another. */ export function canTransition(from: Status, to: Status): boolean { return transitions[from].includes(to) } /** Generates a funding transfer id (`ftr_…`, lexically time-ordered). */ export function generateId(now: Date = new Date()): string { return Id.generateSortable('ftr', now) } /** Zod schemas owned by the funding transfer resource. */ export namespace schema { /** Funding transfer lifecycle status. */ export const Status = z .enum(statuses) .check( z.describe('Lifecycle status of the funding transfer.'), z.meta({ examples: ['awaiting-source'] }), ) /** Customer-safe explanation of a status that needs context. */ export const StatusReason = OpenApi.component( z .object({ code: z .enum(statusReasonCodes) .check( z.describe('Stable machine-readable reason code.'), z.meta({ examples: ['destination_amount_below_minimum'] }), ), message: z.string().check( z.describe('Human-readable explanation of the reason.'), z.meta({ examples: ['The verified destination amount did not satisfy the quoted minimum.'], }), ), }) .check(z.describe('Why the transfer is in its current status, when context is needed.')), 'FundingTransferStatusReason', ) /** Funding method selected at creation. */ export const Method = z .enum(methods) .check(z.describe('How the caller funds the transfer.'), z.meta({ examples: ['transaction'] })) /** Amount mode selected at creation. */ export const Mode = z .enum(modes) .check( z.describe('Which side of the transfer the quoted amount fixes.'), z.meta({ examples: ['exactSource'] }), ) /** One fee applied to the transfer. */ export const Fee = OpenApi.component( z .object({ amount: Schema.TokenAmount, side: z .enum(['destination', 'source']) .check( z.describe('Which side of the transfer the fee is taken from.'), z.meta({ examples: ['destination'] }), ), token: Provider.schema.TokenRef, type: z .enum(['provider']) .check(z.describe('What the fee pays for.'), z.meta({ examples: ['provider'] })), }) .check(z.describe('One fee applied to the transfer.')), 'FundingTransferFee', ) /** Freshness and validity window of the selected quote. */ export const Quote = OpenApi.component( z .object({ expiresAt: z.iso .datetime() .check( z.describe('When the quoted terms stop being executable (ISO 8601).'), z.meta({ examples: ['2026-07-30T04:01:00.000Z'] }), ), sampledAt: z.iso .datetime() .check( z.describe('When the provider produced the quote (ISO 8601).'), z.meta({ examples: ['2026-07-30T04:00:00.000Z'] }), ), }) .check(z.describe('Freshness and validity window of the selected quote.')), 'FundingTransferQuote', ) const hashesCheck = (description: string) => [ z.describe(description), z.meta({ examples: [[`0x${'aa'.repeat(32)}`]] }), ] const snapshotFields = { destinationAmount: z.optional(Schema.TokenAmount), destinationAmountMin: z.optional(Schema.TokenAmount), destinationChain: Provider.schema.ChainRef, destinationToken: Provider.schema.TokenRef, destinationTransactionHashes: z.optional( z.array(z.string()).check(...hashesCheck('Verified destination transaction references.')), ), fees: z.array(Fee).check(z.describe('Fees applied to the transfer.')), method: Method, mode: Mode, provider: Provider.schema.ProviderRef, quote: Quote, recipient: z .string() .check( z.describe('Final beneficiary of the transfer.'), z.meta({ examples: [`0x${'11'.repeat(20)}`] }), ), refundAddress: z.optional( z .string() .check( z.describe('Source-chain refund recipient (deposit-address method).'), z.meta({ examples: [`0x${'22'.repeat(20)}`] }), ), ), refundAmount: z.optional(Schema.TokenAmount), refundTransactionHashes: z.optional( z.array(z.string()).check(...hashesCheck('Verified refund transaction references.')), ), sender: z.optional( z .string() .check( z.describe('Source-chain account that signs the funding action (transaction method).'), z.meta({ examples: [`0x${'22'.repeat(20)}`] }), ), ), sourceAmount: Schema.TokenAmount, sourceAmountMax: z.optional(Schema.TokenAmount), sourceChain: Provider.schema.ChainRef, sourceToken: Provider.schema.TokenRef, sourceTransactionHashes: z.optional( z.array(z.string()).check(...hashesCheck('Verified source transaction references.')), ), } /** * The stored public snapshot: every transfer field except lifecycle columns * (`id`, `status`, `statusReason`, `version`, `createdAt`, `updatedAt`), * which live in typed columns and merge in at serialization. */ export const Snapshot = z .object(snapshotFields) .check(z.describe('Quote terms and reconciled public fields of a funding transfer.')) const fundingTransferFields = { createdAt: z.iso .datetime() .check( z.describe('When the transfer was created (ISO 8601).'), z.meta({ examples: ['2026-07-30T04:00:00.000Z'] }), ), destinationAmount: snapshotFields.destinationAmount, destinationAmountMin: snapshotFields.destinationAmountMin, destinationChain: snapshotFields.destinationChain, destinationToken: snapshotFields.destinationToken, destinationTransactionHashes: snapshotFields.destinationTransactionHashes, fees: snapshotFields.fees, id: z .string() .check( z.describe('Funding transfer id (`ftr_…`, lexically time-ordered).'), z.meta({ examples: ['ftr_001785729600000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), method: snapshotFields.method, mode: snapshotFields.mode, provider: snapshotFields.provider, quote: snapshotFields.quote, recipient: snapshotFields.recipient, refundAddress: snapshotFields.refundAddress, refundAmount: snapshotFields.refundAmount, refundTransactionHashes: snapshotFields.refundTransactionHashes, sender: snapshotFields.sender, sourceAmount: snapshotFields.sourceAmount, sourceAmountMax: snapshotFields.sourceAmountMax, sourceChain: snapshotFields.sourceChain, sourceToken: snapshotFields.sourceToken, sourceTransactionHashes: snapshotFields.sourceTransactionHashes, status: Status, statusReason: z.optional(StatusReason), updatedAt: z.iso .datetime() .check( z.describe('When the transfer last materially changed (ISO 8601).'), z.meta({ examples: ['2026-07-30T04:00:20.000Z'] }), ), version: z .number() .check( z.int(), z.positive(), z.describe('Monotonic revision that increments whenever the transfer materially changes.'), z.meta({ examples: [1] }), ), } /** A funding transfer as returned by reads. Reads never include `action`. */ export const FundingTransfer = OpenApi.component( z.object(fundingTransferFields).check(z.describe('One durable funding transfer.')), 'FundingTransfer', ) /** A created funding transfer: the read shape plus the executable action. */ export const CreatedFundingTransfer = OpenApi.component( z .object({ action: Action.schema.Action, ...fundingTransferFields }) .check(z.describe('The created funding transfer, including its one-time executable action.')), 'CreatedFundingTransfer', ) /** Request body accepted by funding transfer creation. */ export const CreateRequest = OpenApi.component( z .strictObject({ amount: z .string() .check( z.regex(/^[1-9]\d*$/), z.describe('Amount in base units of the token the mode fixes.'), z.meta({ examples: ['1000000'] }), ), destinationToken: z .string() .check( z.minLength(1), z.describe('Tempo destination token symbol, contract address, or token key.'), z.meta({ examples: ['pathusd'] }), ), mode: Mode, provider: z .optional(z.string().check(z.minLength(1))) .check( z.describe('Funding provider ID. Omit to select the best available provider.'), z.meta({ examples: ['relay'] }), ), recipient: z .string() .check( z.regex(/^0x[0-9a-fA-F]{40}$/), z.describe('Final beneficiary of the transfer.'), z.meta({ examples: [`0x${'11'.repeat(20)}`] }), ), sender: z .string() .check( z.regex(/^0x[0-9a-fA-F]{40}$/), z.describe('Source-chain account that signs the funding action.'), z.meta({ examples: [`0x${'22'.repeat(20)}`] }), ), slippageBps: z.optional( z .number() .check( z.int(), z.gte(0), z.lte(10_000), z.describe('Maximum acceptable slippage in basis points.'), z.meta({ examples: [50] }), ), ), sourceChain: z .string() .check( z.describe('Source chain CAIP-2 id, slug, or alias.'), z.meta({ examples: ['base', 'eip155:8453'] }), ), sourceToken: z .string() .check( z.minLength(1), z.describe('Source token symbol, contract address, or token key.'), z.meta({ examples: ['usdc'] }), ), }) .check(z.describe('Funding transfer creation request.')), 'CreateFundingTransferRequest', ) } /** Stored public snapshot of a funding transfer (see {@link schema.Snapshot}). */ export type Snapshot = z.output /** Customer-safe status reason (see {@link schema.StatusReason}). */ export type StatusReason = z.output /** A funding transfer as returned by reads (see {@link schema.FundingTransfer}). */ export type Public = z.output /** A created funding transfer (see {@link schema.CreatedFundingTransfer}). */ export type Created = z.output /** A validated creation request (see {@link schema.CreateRequest}). */ export type CreateRequest = z.output /** What an associated transaction reference evidences. */ export const transactionRoles = ['destination', 'refund', 'source'] as const /** Role of a verified transaction reference on a transfer. */ export type TransactionRole = (typeof transactionRoles)[number] /** Bounded private provider state retained for transfer reconciliation. */ export type ProviderState = Record type Staged = { /** Funding transfer record committed with the webhook obligations. */ record: FundingTransfers.Record /** Webhook queue references committed with the transfer. */ references: Webhooks.QueueReference[] } /** * Creates a durable funding transfer: the row plus its version-1 event commit * in one transaction, so every stored version has an immutable public record. */ export async function create(db: Db.Db, input: create.Input): Promise { return db.transaction(async (tx) => (await createIn(tx, input)).record) } /** * Creates the transfer row and its version-1 event inside the caller's * transaction, for writes that must commit atomically with sibling state * (idempotency completion). */ export async function createIn(tx: Db.Db, input: create.Input): Promise { const now = new Date() const snapshot = parseSnapshot(input.snapshot) const record: FundingTransfers.Record = { apiKeyId: input.apiKeyId, createdAt: now.toISOString(), environment: input.environment, id: input.id ?? generateId(now), method: snapshot.method, mode: snapshot.mode, orgId: input.orgId, projectId: input.projectId ?? null, providerId: snapshot.provider.id, providerState: input.providerState ?? null, quoteExpiresAt: snapshot.quote.expiresAt, snapshot, status: 'awaiting-source', statusReason: null, updatedAt: now.toISOString(), version: 1, } const created = await FundingTransfers.insert(tx, record) const event = await FundingTransferEvents.insert(tx, { createdAt: record.createdAt, snapshot: toPublic(created), status: created.status, transferId: created.id, version: created.version, }) const { references } = await TransferWebhook.stage(tx, { changes: [{ event, record: created }], }) return { record: created, references } } export declare namespace createIn { /** Created transfer and webhook queue references committed with it. */ type Result = Staged } export declare namespace create { /** Fields required to create a funding transfer. */ type Input = { /** API key that created the transfer. */ apiKeyId: string /** Key environment the transfer belongs to. */ environment: 'production' | 'sandbox' /** Transfer id override for deterministic tests. */ id?: string | undefined /** Owning organization id (`org_…`). */ orgId: string /** Attributed project id, when the creating key is project-attributed. */ projectId?: string | undefined /** Private provider correlation retained for reconciliation. */ providerState?: ProviderState | undefined /** The public snapshot captured from the selected quote. */ snapshot: Snapshot } } /** Atomically assigns one verified source transaction and starts reconciliation. */ export async function registerSourceTransaction( db: Db.Db, options: registerSourceTransaction.Options, ): Promise { return db.transaction(async (tx) => { const current = await FundingTransfers.getForUpdate(tx, options.id) if (!current) return { type: 'not_found' } const existing = await FundingTransferTransactions.getSource(tx, { chainId: options.chainId, transactionRef: options.transactionHash, }) if (existing) { if (existing.transferId !== current.id) return { current, type: 'conflict' } if (current.status !== 'processing' && current.status !== 'completed') return { current, type: 'invalid' } return { record: current, type: 'replayed' } } if (current.status !== 'awaiting-source') return { current, type: 'invalid' } const claimed = await FundingTransferTransactions.claimSource(tx, { chainId: options.chainId, createdAt: new Date().toISOString(), role: 'source', transactionRef: options.transactionHash, transferId: options.id, }) if (claimed.type === 'conflict') return { current, type: 'conflict' } if (claimed.type === 'replayed') return { record: current, type: 'replayed' } const result = await transitionIn(tx, { current, expectedVersion: current.version, id: current.id, providerState: { ...current.providerState, ...options.providerState }, snapshot: { ...current.snapshot, sourceTransactionHashes: [claimed.record.transactionRef], }, status: 'processing', }) if (result.type !== 'applied') throw new AtomicTransitionError() return { record: result.record, references: result.references, type: 'registered' } }) } export declare namespace registerSourceTransaction { /** Verified source evidence and private provider correlation. */ type Options = { /** Source chain CAIP-2 id. */ chainId: string /** Transfer id (`ftr_…`). */ id: string /** Private state added for destination reconciliation. */ providerState: ProviderState /** Verified source transaction hash. */ transactionHash: string } /** Source transaction registration outcome. */ type Result = | { current: FundingTransfers.Record; type: 'conflict' } | { current: FundingTransfers.Record; type: 'invalid' } | (Staged & { type: 'registered' }) | { record: FundingTransfers.Record; type: 'replayed' } | { type: 'not_found' } } /** Atomically removes invalidated source evidence and accepts a replacement. */ export async function reopenSourceTransaction( db: Db.Db, options: reopenSourceTransaction.Options, ): Promise { return db.transaction(async (tx) => { const current = await FundingTransfers.getForUpdate(tx, options.id) if (!current) return { type: 'not_found' } if (current.status !== 'processing') return { current, type: 'invalid' } const removed = await FundingTransferTransactions.removeSource(tx, { chainId: options.chainId, transactionHash: options.transactionHash, transferId: current.id, }) if (!removed) return { current, type: 'invalid' } const { sourceTransactionHashes: _sourceTransactionHashes, ...snapshot } = current.snapshot const result = await transitionIn(tx, { current, expectedVersion: current.version, id: current.id, providerState: options.providerState, snapshot, status: 'awaiting-source', }) if (result.type !== 'applied') throw new AtomicTransitionError() return { record: result.record, references: result.references, type: 'reopened' } }) } export declare namespace reopenSourceTransaction { /** Source evidence to remove after it disappears from the canonical chain. */ type Options = { /** Source chain CAIP-2 id. */ chainId: string /** Transfer id (`ftr_…`). */ id: string /** Private provider state retained while awaiting replacement evidence. */ providerState: ProviderState /** Invalidated source transaction hash. */ transactionHash: string } /** Source transaction reopening outcome. */ type Result = | { current: FundingTransfers.Record; type: 'invalid' } | (Staged & { type: 'reopened' }) | { type: 'not_found' } } /** Atomically records a verified destination transaction and completes a transfer. */ export async function complete(db: Db.Db, options: complete.Options): Promise { return db.transaction(async (tx) => { const current = await FundingTransfers.getForUpdate(tx, options.id) if (!current) return { type: 'not_found' } if (current.status === 'completed') { const transactions = await FundingTransferTransactions.listByTransfer(tx, current.id) const existing = transactions.some( (transaction) => transaction.chainId === options.chainId && transaction.role === 'destination' && transaction.transactionRef === options.transactionHash.toLowerCase(), ) return existing ? { record: current, type: 'replayed' } : { current, type: 'invalid' } } if (current.status !== 'processing') return { current, type: 'invalid' } const transaction = await FundingTransferTransactions.insert(tx, { chainId: options.chainId, createdAt: new Date().toISOString(), role: 'destination', transactionRef: options.transactionHash, transferId: options.id, }) if (!transaction) throw new AtomicTransitionError() const result = await transitionIn(tx, { current, expectedVersion: current.version, id: current.id, snapshot: { ...current.snapshot, destinationAmount: options.destinationAmount, destinationTransactionHashes: [transaction.transactionRef], }, status: 'completed', }) if (result.type !== 'applied') throw new AtomicTransitionError() return { record: result.record, references: result.references, type: 'completed' } }) } export declare namespace complete { /** Verified destination evidence for one processing transfer. */ type Options = { /** Destination chain CAIP-2 id. */ chainId: string /** Exact verified destination amount. */ destinationAmount: NonNullable /** Transfer id (`ftr_…`). */ id: string /** Verified destination transaction hash. */ transactionHash: string } /** Transfer completion outcome. */ type Result = | { current: FundingTransfers.Record; type: 'invalid' } | (Staged & { type: 'completed' }) | { record: FundingTransfers.Record; type: 'replayed' } | { type: 'not_found' } } /** * Applies a version-guarded material change: a status transition, a snapshot * update, or both. The row update and its immutable event commit in one * transaction; a stale `expectedVersion` or disallowed transition applies * nothing, so concurrent observations cannot overwrite newer evidence. */ export async function transition( db: Db.Db, options: transition.Options, ): Promise { return db.transaction((tx) => transitionIn(tx, options)) } async function transitionIn( tx: Db.Db, options: transition.Options & { current?: FundingTransfers.Record | undefined }, ): Promise { const now = new Date() const current = options.current ?? (await FundingTransfers.get(tx, options.id)) if (!current) return { type: 'not_found' } if (current.version !== options.expectedVersion) return { current, type: 'stale' } const status = options.status ?? current.status if (status !== current.status && !canTransition(current.status, status)) return { current, type: 'invalid' } const updated = await FundingTransfers.update(tx, { expectedVersion: options.expectedVersion, id: options.id, providerState: options.providerState === undefined ? current.providerState : options.providerState, snapshot: options.snapshot === undefined ? current.snapshot : parseSnapshot(options.snapshot), status, statusReason: options.statusReason ?? (status === current.status ? current.statusReason : null), updatedAt: now.toISOString(), version: current.version + 1, }) if (!updated) { const latest = await FundingTransfers.get(tx, options.id) return latest ? { current: latest, type: 'stale' } : { type: 'not_found' } } const event = await FundingTransferEvents.insert(tx, { createdAt: updated.updatedAt, snapshot: toPublic(updated), status: updated.status, transferId: updated.id, version: updated.version, }) const { references } = await TransferWebhook.stage(tx, { changes: [{ event, record: updated }], }) return { record: updated, references, type: 'applied' } } export declare namespace transition { /** Fields describing a material change to apply. */ type Options = { /** The version the change was computed against. */ expectedVersion: number /** Transfer id (`ftr_…`). */ id: string /** Replacement private provider state; omitted keeps the stored value. */ providerState?: ProviderState | null | undefined /** Replacement public snapshot; omitted keeps the stored snapshot. */ snapshot?: Snapshot | undefined /** New status; omitted keeps the current status (snapshot-only change). */ status?: Status | undefined /** Customer-safe reason attached to the new status. */ statusReason?: StatusReason | undefined } /** Outcome of a guarded transition. */ type Result = | (Staged & { type: 'applied' }) | { current: FundingTransfers.Record; type: 'invalid' } | { current: FundingTransfers.Record; type: 'stale' } | { type: 'not_found' } } /** Serializes a created row with its one-time executable action. */ export function toCreated(record: FundingTransfers.Record, action: Action.Action): Created { return schema.CreatedFundingTransfer.parse({ action, ...toPublic(record) }) } /** Serializes a stored row to the public read shape. Never includes actions. */ export function toPublic(record: FundingTransfers.Record): Public { return schema.FundingTransfer.parse({ ...record.snapshot, createdAt: record.createdAt, id: record.id, status: record.status, ...(record.statusReason === null ? {} : { statusReason: record.statusReason }), updatedAt: record.updatedAt, version: record.version, }) } function parseSnapshot(snapshot: Snapshot): Snapshot { const parsed = schema.Snapshot.parse(snapshot) return { ...parsed, quote: { ...parsed.quote, expiresAt: new Date(parsed.quote.expiresAt).toISOString(), }, } } class AtomicTransitionError extends Error { override name = 'Funding.Transfer.AtomicTransitionError' }