import * as z from 'zod/mini' import type * as Db from '../../db/Db.js' import * as FundingDepositAddresses from '../../db/tables/fundingDepositAddresses.js' import * as FundingDeposits from '../../db/tables/fundingDeposits.js' import * as Id from '../Id.js' import * as OpenApi from '../OpenApi.js' import * as Schema from '../Schema.js' import * as Provider from './Provider.js' /** Mechanisms that can first detect a funding deposit. */ export const detectionTriggers = ['chain', 'manual', 'poll', 'webhook'] as const /** Mechanism that first detected a funding deposit. */ export type DetectionTrigger = (typeof detectionTriggers)[number] /** * Public funding deposit statuses: * * - `action-required`: Automatic processing stopped and requires recovery or operator action. * - `bridging`: The provider is moving the source funds to Tempo. * - `completed`: The full required amount reached the recipient. * - `detected`: The provider reported a source transfer that awaits chain verification. * - `refunded`: A verified refund reached the configured refund address. * - `refunding`: A refund started but is not yet verified. * - `settling`: Tempo is completing the required destination amount after provider delivery. */ export const statuses = [ 'action-required', 'bridging', 'completed', 'detected', 'refunded', 'refunding', 'settling', ] as const /** Lifecycle status of a detected funding deposit. */ export type Status = (typeof statuses)[number] /** Statuses that never change after verified chain evidence. */ export const terminalStatuses = ['completed', 'refunded'] as const satisfies readonly Status[] /** * Customer-safe codes explaining a deposit that needs attention: * * - `delivery_failed`: The provider or Tempo could not complete destination delivery. * - `delivery_liquidity_unavailable`: The selected strategy cannot obtain the required destination amount. * - `manual_recovery_required`: Automatic processing cannot safely continue. * - `refund_failed`: The provider could not refund the source funds. * - `source_amount_not_supported`: The source amount is outside the supported limits. * - `source_token_mismatch`: The transfer used a token other than the configured source token. * - `subsidy_balance_unavailable`: Tempo lacks enough pathUSD for the required subsidy. * - `subsidy_failed`: Tempo could not deliver the required subsidy. */ export const statusReasonCodes = [ 'delivery_failed', 'delivery_liquidity_unavailable', 'manual_recovery_required', 'refund_failed', 'source_amount_not_supported', 'source_token_mismatch', 'subsidy_balance_unavailable', 'subsidy_failed', ] as const /** Stable customer-safe deposit status reason code. */ export type StatusReasonCode = (typeof statusReasonCodes)[number] /** Bounded private state used by deposit reconciliation. */ export type PrivateState = Record const transitions: Record = { 'action-required': ['bridging', 'completed', 'refunded', 'refunding', 'settling'], bridging: ['action-required', 'completed', 'refunding', 'settling'], completed: [], detected: ['action-required', 'bridging', 'completed', 'refunding', 'settling'], refunded: [], refunding: ['action-required', 'refunded'], settling: ['action-required', 'completed', 'refunding'], } /** Returns whether a deposit status is terminal. */ export function isTerminal(status: Status): boolean { return (terminalStatuses as readonly Status[]).includes(status) } /** Returns whether the lifecycle allows moving between deposit statuses. */ export function canTransition(from: Status, to: Status): boolean { return transitions[from].includes(to) } /** Generates a funding deposit id (`fdp_…`, lexically time-ordered). */ export function generateId(now: Date = new Date()): string { return Id.generateSortable('fdp', now) } /** Zod schemas owned by the funding deposit resource. */ export namespace schema { const transactionHashes = (description: string) => z .readonly(z.array(z.string())) .check(z.describe(description), z.meta({ examples: [[`0x${'aa'.repeat(32)}`]] })) /** Mechanism that first detected a funding deposit. */ export const DetectionTrigger = z .enum(detectionTriggers) .check(z.describe('How the deposit was first detected.'), z.meta({ examples: ['webhook'] })) /** Funding deposit lifecycle status. */ export const Status = z .enum(statuses) .check( z.describe('Current delivery status of the deposit.'), z.meta({ examples: ['detected'] }), ) /** Customer-safe explanation of a deposit status. */ export const StatusReason = OpenApi.component( z .object({ code: z .enum(statusReasonCodes) .check( z.describe('Stable machine-readable reason code.'), z.meta({ examples: ['delivery_liquidity_unavailable'] }), ), message: z .string() .check( z.describe('Human-readable explanation of the current status.'), z.meta({ examples: ['The required destination amount is not currently available.'] }), ), }) .check(z.describe('Why a deposit needs attention or recovery.')), 'FundingDepositStatusReason', ) /** Public fields persisted as the deposit snapshot. */ export const Snapshot = z .object({ depositAddressId: z .string() .check( z.regex(/^fda_[A-Za-z0-9_-]+$/), z.describe('Funding deposit address that detected the transfer.'), z.meta({ examples: ['fda_001785792000000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), destinationAmount: z.optional(Schema.TokenAmount), destinationAmountRequired: z .optional(Schema.TokenAmount) .check(z.describe('Destination amount required for completion, when known.')), destinationChain: Provider.schema.ChainRef, destinationToken: Provider.schema.TokenRef, destinationTransactionHashes: z.optional( transactionHashes('Verified destination transaction references.'), ), provider: Provider.schema.ProviderRef, recipient: z .string() .check( z.regex(/^0x[0-9a-fA-F]{40}$/), z.describe('Tempo account that receives the completed deposit.'), z.meta({ examples: [`0x${'11'.repeat(20)}`] }), ), refundAddress: z .string() .check( z.describe('Source-chain address that receives a refund.'), z.meta({ examples: ['TJRabPrwbZy45sbavfcjinPJC18kjpRTv8'] }), ), refundAmount: z.optional(Schema.TokenAmount), refundTransactionHashes: z.optional( transactionHashes('Verified source-chain refund transaction references.'), ), sender: z .optional(z.string()) .check( z.describe('Observed source-chain sender, when available.'), z.meta({ examples: ['TJRabPrwbZy45sbavfcjinPJC18kjpRTv8'] }), ), sourceAmount: z .optional(Schema.TokenAmount) .check(z.describe('Verified source amount, when source evidence is available.')), sourceChain: Provider.schema.ChainRef, sourceToken: Provider.schema.TokenRef, sourceTransactionHashes: transactionHashes( 'Provider-observed source transaction references.', ), }) .check( z.describe('Public route, provider observations, and verified evidence for one deposit.'), ) /** One detected funding deposit. */ export const FundingDeposit = OpenApi.component( z .object({ createdAt: z.iso .datetime() .check( z.describe('When the deposit was first detected (ISO 8601).'), z.meta({ examples: ['2026-08-04T00:01:00.000Z'] }), ), depositAddressId: Snapshot.shape.depositAddressId, detectionTrigger: z.optional(DetectionTrigger), destinationAmount: Snapshot.shape.destinationAmount, destinationAmountRequired: Snapshot.shape.destinationAmountRequired, destinationChain: Snapshot.shape.destinationChain, destinationToken: Snapshot.shape.destinationToken, destinationTransactionHashes: Snapshot.shape.destinationTransactionHashes, id: z .string() .check( z.regex(/^fdp_[A-Za-z0-9_-]+$/), z.describe('Funding deposit id (`fdp_…`).'), z.meta({ examples: ['fdp_001785792060000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), provider: Snapshot.shape.provider, recipient: Snapshot.shape.recipient, refundAddress: Snapshot.shape.refundAddress, refundAmount: Snapshot.shape.refundAmount, refundTransactionHashes: Snapshot.shape.refundTransactionHashes, sender: Snapshot.shape.sender, sourceAmount: Snapshot.shape.sourceAmount, sourceChain: Snapshot.shape.sourceChain, sourceToken: Snapshot.shape.sourceToken, sourceTransactionHashes: Snapshot.shape.sourceTransactionHashes, sourceTransferIndex: z .optional(z.number().check(z.int(), z.minimum(0))) .check( z.describe('Verified transfer position within the source transaction.'), z.meta({ examples: [0] }), ), status: Status, statusReason: z.optional(StatusReason), updatedAt: z.iso .datetime() .check( z.describe('When the deposit last materially changed (ISO 8601).'), z.meta({ examples: ['2026-08-04T00:02:00.000Z'] }), ), }) .check(z.describe('One detected deposit and its delivery status.')), 'FundingDeposit', ) } /** Stored public snapshot of a funding deposit. */ export type Snapshot = z.output /** Customer-safe funding deposit status reason. */ export type StatusReason = z.output /** A funding deposit as returned by reads. */ export type Public = z.output /** Creates a durable record for one provider-observed source transaction. */ export async function create(db: Db.Db, input: create.Input): Promise { return FundingDeposits.insert(db, await record(db, input)) } /** Creates or returns the deposit for one verified chain transfer. */ export async function createFromSource( db: Db.Db, input: createFromSource.Input, ): Promise { const stored = await record(db, { detectionTrigger: 'chain', now: input.now, providerRequestId: null, snapshot: input.snapshot, sourceTransactionHash: input.sourceTransactionHash, sourceTransferIndex: input.sourceTransferIndex, }) return FundingDeposits.insertOrGetSource(db, { ...stored, sourceTransferIndex: input.sourceTransferIndex, }) } export declare namespace createFromSource { /** Verified source transfer used to create a chain-detected deposit. */ type Input = { /** Detection time override for deterministic tests. */ now?: Date | undefined /** Public route and verified source amount. */ snapshot: Snapshot /** Source transaction containing the deposited transfer. */ sourceTransactionHash: string /** Verified transfer position within the source transaction. */ sourceTransferIndex: number } } async function record( db: Db.Db, input: Omit & { providerRequestId: string | null }, ): Promise { const now = input.now ?? new Date() const depositAddressId = schema.Snapshot.shape.depositAddressId.parse( input.snapshot.depositAddressId, ) const address = await FundingDepositAddresses.get(db, depositAddressId) if (!address) throw new DepositAddressNotFoundError(depositAddressId) const snapshot = schema.Snapshot.parse({ ...input.snapshot, depositAddressId: address.id, destinationChain: address.snapshot.destinationChain, destinationToken: address.snapshot.destinationToken, provider: address.snapshot.provider, recipient: address.snapshot.recipient, refundAddress: address.snapshot.refundAddress, sourceChain: address.snapshot.sourceChain, sourceToken: address.snapshot.sourceToken, sourceTransactionHashes: [input.sourceTransactionHash], }) return { createdAt: now.toISOString(), depositAddressId: snapshot.depositAddressId, detectionTrigger: input.detectionTrigger ?? null, environment: address.environment, id: input.id ?? generateId(now), orgId: address.orgId, pollObservedAt: input.pollObservedAt ?? null, projectId: address.projectId, providerOutputAmount: input.providerOutputAmount ?? null, providerOutputToken: address.providerOutputToken, providerRequestId: input.providerRequestId, providerRequestIds: [ ...new Set([ ...(input.providerRequestId === null ? [] : [input.providerRequestId]), ...(input.providerRequestIds ?? []), ]), ], providerState: input.providerState ?? null, providerTransactionHashes: input.providerTransactionHashes ?? [], providerTransferIndex: input.providerTransferIndex ?? 0, retryState: input.retryState ?? null, settlementTransaction: input.settlementTransaction ?? null, settlementTransactionHash: input.settlementTransactionHash ?? null, snapshot, sourceChainId: snapshot.sourceChain.id, sourceTransactionHash: input.sourceTransactionHash, sourceTransferIndex: input.sourceTransferIndex ?? null, status: 'detected', statusReason: null, subsidyAmount: input.subsidyAmount ?? null, tempoGasPaid: input.tempoGasPaid ?? null, updatedAt: now.toISOString(), version: 1, webhookReceivedAt: input.webhookReceivedAt ?? null, } } export declare namespace create { /** Fields required to persist a provider-observed deposit. */ type Input = { /** Mechanism that first detected the deposit. */ detectionTrigger?: DetectionTrigger | undefined /** Deposit id override for deterministic tests. */ id?: string | undefined /** Detection time override for deterministic tests. */ now?: Date | undefined /** When polling first observed the provider request. */ pollObservedAt?: string | undefined /** Provider output amount confirmed on Tempo. */ providerOutputAmount?: z.output | undefined /** Provider request that reported the source transaction. */ providerRequestId: string /** Private provider request identifiers. */ providerRequestIds?: readonly string[] | undefined /** Bounded private provider state. */ providerState?: PrivateState | undefined /** Provider-leg transaction references. */ providerTransactionHashes?: readonly string[] | undefined /** Deposit position within the provider request. */ providerTransferIndex?: number | undefined /** Bounded private retry state. */ retryState?: PrivateState | undefined /** Persisted settlement transaction bytes for exactly-once rebroadcast. */ settlementTransaction?: string | undefined /** Persisted settlement transaction hash. */ settlementTransactionHash?: string | undefined /** Public deposit snapshot. */ snapshot: Snapshot /** Source transaction containing the deposited transfer. */ sourceTransactionHash: string /** Verified transfer position within the source transaction. */ sourceTransferIndex?: number | undefined /** Destination token amount supplied by Tempo. */ subsidyAmount?: z.output | undefined /** Tempo gas paid for settlement, in base units. */ tempoGasPaid?: string | undefined /** When Tempo first received an authenticated provider webhook. */ webhookReceivedAt?: string | undefined } } /** Applies a version-guarded material deposit change. */ export async function transition( db: Db.Db, options: transition.Options, ): Promise { const current = await FundingDeposits.get(db, 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 snapshot = options.snapshot === undefined ? current.snapshot : schema.Snapshot.parse({ ...options.snapshot, depositAddressId: current.snapshot.depositAddressId, destinationAmountRequired: current.snapshot.destinationAmountRequired ?? options.snapshot.destinationAmountRequired, destinationChain: current.snapshot.destinationChain, destinationToken: current.snapshot.destinationToken, provider: current.snapshot.provider, recipient: current.snapshot.recipient, refundAddress: current.snapshot.refundAddress, sender: current.snapshot.sender ?? options.snapshot.sender, sourceAmount: current.snapshot.sourceAmount ?? options.snapshot.sourceAmount, sourceChain: current.snapshot.sourceChain, sourceToken: current.snapshot.sourceToken, sourceTransactionHashes: current.snapshot.sourceTransactionHashes, }) const updated = await FundingDeposits.update(db, { expectedVersion: options.expectedVersion, id: options.id, providerOutputAmount: options.providerOutputAmount ?? current.providerOutputAmount, providerRequestId: current.providerRequestId ?? options.providerRequestId ?? null, providerRequestIds: options.providerRequestIds ?? current.providerRequestIds, providerState: options.providerState ?? current.providerState, providerTransactionHashes: options.providerTransactionHashes ?? current.providerTransactionHashes, providerTransferIndex: options.providerTransferIndex ?? current.providerTransferIndex, retryState: options.retryState ?? current.retryState, settlementTransaction: options.settlementTransaction ?? current.settlementTransaction, settlementTransactionHash: options.settlementTransactionHash ?? current.settlementTransactionHash, snapshot, sourceTransferIndex: current.sourceTransferIndex ?? options.sourceTransferIndex ?? null, status, statusReason: options.statusReason ?? (status === current.status ? current.statusReason : null), subsidyAmount: options.subsidyAmount ?? current.subsidyAmount, tempoGasPaid: options.tempoGasPaid ?? current.tempoGasPaid, updatedAt: new Date().toISOString(), version: current.version + 1, }) if (updated) return { record: updated, type: 'applied' } const latest = await FundingDeposits.get(db, options.id) return latest ? { current: latest, type: 'stale' } : { type: 'not_found' } } export declare namespace transition { /** Fields describing a material deposit change. */ type Options = { /** Version that the change was computed against. */ expectedVersion: number /** Funding deposit id (`fdp_…`). */ id: string /** Provider output amount confirmed on Tempo. */ providerOutputAmount?: z.output | undefined /** Provider request attached after chain-first detection. */ providerRequestId?: string | undefined /** Private provider request identifiers. */ providerRequestIds?: readonly string[] | undefined /** Bounded private provider state. */ providerState?: PrivateState | undefined /** Provider-leg transaction references. */ providerTransactionHashes?: readonly string[] | undefined /** Deposit position within the attached provider request. */ providerTransferIndex?: number | undefined /** Bounded private retry state. */ retryState?: PrivateState | undefined /** Persisted settlement transaction bytes for exactly-once rebroadcast. */ settlementTransaction?: string | undefined /** Persisted settlement transaction hash. */ settlementTransactionHash?: string | undefined /** Replacement public snapshot. */ snapshot?: Snapshot | undefined /** Verified transfer position within the source transaction. */ sourceTransferIndex?: number | undefined /** New lifecycle status. */ status?: Status | undefined /** Customer-safe reason attached to the current status. */ statusReason?: StatusReason | undefined /** Destination token amount supplied by Tempo. */ subsidyAmount?: z.output | undefined /** Tempo gas paid for settlement, in base units. */ tempoGasPaid?: string | undefined } /** Outcome of a guarded deposit transition. */ type Result = | { record: FundingDeposits.Record; type: 'applied' } | { current: FundingDeposits.Record; type: 'invalid' } | { current: FundingDeposits.Record; type: 'stale' } | { type: 'not_found' } } /** Serializes a stored deposit to the public read shape. */ export function toPublic(record: FundingDeposits.Record): Public { return schema.FundingDeposit.parse({ ...record.snapshot, createdAt: record.createdAt, ...(record.detectionTrigger === null ? {} : { detectionTrigger: record.detectionTrigger }), id: record.id, ...(record.sourceTransferIndex === null ? {} : { sourceTransferIndex: record.sourceTransferIndex }), status: record.status, ...(record.statusReason === null ? {} : { statusReason: record.statusReason }), updatedAt: record.updatedAt, }) } class DepositAddressNotFoundError extends Error { override name = 'FundingDeposit.DepositAddressNotFoundError' constructor(id: string) { super(`Funding deposit address ${id} not found`) } }