From de2a579634f88ff545b55a55a79f34974178ade9 Mon Sep 17 00:00:00 2001 From: michaelvic123 Date: Thu, 25 Jun 2026 23:34:04 +0100 Subject: [PATCH] feat: Stellar transfer lifecycle state machine Implements a guarded state machine for Soroban bridge transfer lifecycle management under src/transfers/state-machine/stellar/. States: pending -> locked -> validated -> submitted -> confirmed -> completed Recovery: failed -> refunded (via /recover endpoint) - transfer-state-machine.types.ts: TransferState, TransitionRecord, TransferLifecycle - transfer-state-machine.service.ts: create/get/transition/recover with transition guards - transfer-state-machine.controller.ts: REST endpoints with Swagger - transfer-state-machine.module.ts: NestJS module, exports service for reuse - app.module.ts: registers TransferStateMachineModule --- apps/api/src/app.module.ts | 4 + .../transfer-state-machine.controller.ts | 59 ++++++++++++ .../stellar/transfer-state-machine.module.ts | 10 ++ .../stellar/transfer-state-machine.service.ts | 91 +++++++++++++++++++ .../stellar/transfer-state-machine.types.ts | 23 +++++ 5 files changed, 187 insertions(+) create mode 100644 apps/api/src/transfers/state-machine/stellar/transfer-state-machine.controller.ts create mode 100644 apps/api/src/transfers/state-machine/stellar/transfer-state-machine.module.ts create mode 100644 apps/api/src/transfers/state-machine/stellar/transfer-state-machine.service.ts create mode 100644 apps/api/src/transfers/state-machine/stellar/transfer-state-machine.types.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 54adfc97..eee3c581 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -22,6 +22,8 @@ import { Transaction } from './transactions/entities/transaction.entity'; import { WalletSession } from './wallet/entities/wallet-session.entity'; import { RecommendationV2Module } from './api/routes/v2/recommendation.module'; import { IntelligenceHubModule } from './intelligence-hub/stellar/intelligence-hub.module'; +import { AssetDiscoveryModule } from './api/assets/discovery/stellar/asset-discovery.module'; +import { TransferStateMachineModule } from './transfers/state-machine/stellar/transfer-state-machine.module'; @Module({ imports: [ @@ -56,6 +58,8 @@ import { IntelligenceHubModule } from './intelligence-hub/stellar/intelligence-h StellarTimeoutModule, RecommendationV2Module, IntelligenceHubModule, + AssetDiscoveryModule, + TransferStateMachineModule, ThrottlerModule.forRoot([ { ttl: 60000, diff --git a/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.controller.ts b/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.controller.ts new file mode 100644 index 00000000..667a03b4 --- /dev/null +++ b/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Post, Param, Body, HttpCode, HttpStatus, BadRequestException } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiResponse } from '@nestjs/swagger'; +import { TransferStateMachineService } from './transfer-state-machine.service'; +import { TransferState, TransferLifecycle } from './transfer-state-machine.types'; + +class TransitionDto { + state: TransferState; +} + +@ApiTags('Transfer State Machine') +@Controller('transfers/state-machine/stellar') +export class TransferStateMachineController { + constructor(private readonly stateMachineService: TransferStateMachineService) {} + + @Post(':transferId') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create state machine for a transfer' }) + @ApiParam({ name: 'transferId', type: 'string' }) + @ApiBody({ schema: { properties: { initialState: { type: 'string' } } }, required: false }) + @ApiResponse({ status: 201, description: 'State machine created' }) + create( + @Param('transferId') transferId: string, + @Body('initialState') initialState?: TransferState, + ): TransferLifecycle { + return this.stateMachineService.create(transferId, initialState); + } + + @Get(':transferId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get current lifecycle state of a transfer' }) + @ApiParam({ name: 'transferId', type: 'string' }) + @ApiResponse({ status: 200, description: 'Transfer lifecycle returned' }) + get(@Param('transferId') transferId: string): TransferLifecycle { + return this.stateMachineService.get(transferId); + } + + @Post(':transferId/transition') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Transition transfer to a new state' }) + @ApiParam({ name: 'transferId', type: 'string' }) + @ApiBody({ schema: { properties: { state: { type: 'string' } }, required: ['state'] } }) + @ApiResponse({ status: 200, description: 'Transition applied' }) + transition( + @Param('transferId') transferId: string, + @Body() dto: TransitionDto, + ): TransferLifecycle { + if (!dto.state) throw new BadRequestException('"state" is required'); + return this.stateMachineService.transition(transferId, dto.state); + } + + @Post(':transferId/recover') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Recover a failed transfer (transitions to refunded)' }) + @ApiParam({ name: 'transferId', type: 'string' }) + @ApiResponse({ status: 200, description: 'Recovery path initiated' }) + recover(@Param('transferId') transferId: string): TransferLifecycle { + return this.stateMachineService.recover(transferId); + } +} diff --git a/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.module.ts b/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.module.ts new file mode 100644 index 00000000..bc287aa2 --- /dev/null +++ b/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TransferStateMachineController } from './transfer-state-machine.controller'; +import { TransferStateMachineService } from './transfer-state-machine.service'; + +@Module({ + controllers: [TransferStateMachineController], + providers: [TransferStateMachineService], + exports: [TransferStateMachineService], +}) +export class TransferStateMachineModule {} diff --git a/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.service.ts b/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.service.ts new file mode 100644 index 00000000..5abc033e --- /dev/null +++ b/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.service.ts @@ -0,0 +1,91 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { TransferState, TransitionRecord, TransferLifecycle } from './transfer-state-machine.types'; + +const TRANSITIONS: Record = { + pending: ['locked', 'failed'], + locked: ['validated', 'failed', 'refunded'], + validated: ['submitted', 'failed', 'refunded'], + submitted: ['confirmed', 'failed'], + confirmed: ['completed', 'failed'], + completed: [], + failed: ['refunded'], + refunded: [], +}; + +interface MachineEntry { + current: TransferState; + history: TransitionRecord[]; +} + +@Injectable() +export class TransferStateMachineService { + private readonly logger = new Logger(TransferStateMachineService.name); + private readonly machines = new Map(); + + create(transferId: string, initial: TransferState = 'pending'): TransferLifecycle { + if (this.machines.has(transferId)) { + this.logger.warn(`State machine for ${transferId} already exists, returning current state`); + return this.get(transferId); + } + + this.machines.set(transferId, { current: initial, history: [] }); + this.logger.log(`Transfer ${transferId} created in state: ${initial}`); + return this.get(transferId); + } + + get(transferId: string): TransferLifecycle { + const entry = this.machines.get(transferId); + if (!entry) { + throw new NotFoundException(`No state machine found for transfer ${transferId}`); + } + + return { + transferId, + current: entry.current, + history: [...entry.history], + isTerminal: TRANSITIONS[entry.current].length === 0, + nextStates: [...TRANSITIONS[entry.current]], + }; + } + + transition(transferId: string, next: TransferState): TransferLifecycle { + const entry = this.machines.get(transferId); + if (!entry) { + throw new NotFoundException(`No state machine found for transfer ${transferId}`); + } + + const allowed = TRANSITIONS[entry.current]; + if (!allowed.includes(next)) { + throw new BadRequestException( + `Invalid transition for transfer ${transferId}: ${entry.current} -> ${next}. Allowed: [${allowed.join(', ')}]`, + ); + } + + const record: TransitionRecord = { from: entry.current, to: next, at: Date.now() }; + entry.history.push(record); + entry.current = next; + + this.logger.log(`Transfer ${transferId}: ${record.from} -> ${next}`); + return this.get(transferId); + } + + // Recovery: reset a stuck failed transfer back to a prior recoverable state + recover(transferId: string): TransferLifecycle { + const entry = this.machines.get(transferId); + if (!entry) { + throw new NotFoundException(`No state machine found for transfer ${transferId}`); + } + + if (entry.current !== 'failed') { + throw new BadRequestException( + `Transfer ${transferId} is not in failed state (current: ${entry.current})`, + ); + } + + return this.transition(transferId, 'refunded'); + } + + delete(transferId: string): void { + this.machines.delete(transferId); + } +} diff --git a/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.types.ts b/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.types.ts new file mode 100644 index 00000000..7c8f8cd9 --- /dev/null +++ b/apps/api/src/transfers/state-machine/stellar/transfer-state-machine.types.ts @@ -0,0 +1,23 @@ +export type TransferState = + | 'pending' + | 'locked' + | 'validated' + | 'submitted' + | 'confirmed' + | 'completed' + | 'failed' + | 'refunded'; + +export interface TransitionRecord { + from: TransferState; + to: TransferState; + at: number; +} + +export interface TransferLifecycle { + transferId: string; + current: TransferState; + history: TransitionRecord[]; + isTerminal: boolean; + nextStates: TransferState[]; +}