Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/backend/src/onchain/onchain.adapter.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
AidPackage,
GetTokenBalanceParams,
GetTokenBalanceResult,
GetTransactionStatusParams,
GetTransactionStatusResult,
} from './onchain.adapter';
import { createHash } from 'crypto';

Expand Down Expand Up @@ -229,6 +231,33 @@ export class MockOnchainAdapter implements OnchainAdapter {
return balanceValue.toString();
}

async getTransactionStatus(
params: GetTransactionStatusParams,
): Promise<GetTransactionStatusResult> {
await Promise.resolve();

// Deterministically decide status based on hash for testing
// If hash ends with 'F', simulate failure. If 'P', simulate pending. Else success.
let status: GetTransactionStatusResult['status'] = 'succeeded';

if (params.transactionHash.endsWith('F')) {
status = 'failed';
} else if (params.transactionHash.endsWith('P')) {
status = 'pending';
} else if (params.transactionHash.endsWith('U')) {
status = 'unknown';
}

return {
transactionHash: params.transactionHash,
status,
timestamp: new Date(),
details: {
adapter: 'mock',
},
};
}

// Legacy methods for backward compatibility
async createClaim(params: CreateClaimParams): Promise<CreateClaimResult> {
await Promise.resolve();
Expand Down
18 changes: 18 additions & 0 deletions app/backend/src/onchain/onchain.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ export interface GetTokenBalanceResult {
timestamp: Date;
}

export interface GetTransactionStatusParams {
transactionHash: string;
}

export interface GetTransactionStatusResult {
transactionHash: string;
status: 'pending' | 'succeeded' | 'failed' | 'unknown';
timestamp: Date;
details?: Record<string, any>;
}

// Legacy interfaces kept for backward compatibility
export interface CreateClaimParams {
claimId: string;
Expand Down Expand Up @@ -219,6 +230,13 @@ export interface OnchainAdapter {
params: GetTokenBalanceParams,
): Promise<GetTokenBalanceResult>;

/**
* Get the status of a transaction by its hash
*/
getTransactionStatus(
params: GetTransactionStatusParams,
): Promise<GetTransactionStatusResult>;

// Legacy methods - kept for backward compatibility
createClaim(params: CreateClaimParams): Promise<CreateClaimResult>;
disburse(params: DisburseParams): Promise<DisburseResult>;
Expand Down
3 changes: 2 additions & 1 deletion app/backend/src/onchain/onchain.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { OnchainService } from './onchain.service';
import { LedgerBackfillService } from './ledger-backfill.service';
import { LedgerReconciliationService } from './ledger-reconciliation.service';
import { LedgerAdminController } from './ledger-admin.controller';
import { TransactionController } from './transaction.controller';
import { JobsModule } from '../jobs/jobs.module';

/**
Expand Down Expand Up @@ -55,7 +56,7 @@ const onchainAdapterProvider: Provider = {
}),
JobsModule,
],
controllers: [LedgerAdminController],
controllers: [LedgerAdminController, TransactionController],
providers: [
MockOnchainAdapter,
SorobanAdapter,
Expand Down
55 changes: 55 additions & 0 deletions app/backend/src/onchain/soroban.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
AidPackage,
GetTokenBalanceParams,
GetTokenBalanceResult,
GetTransactionStatusParams,
GetTransactionStatusResult,
} from './onchain.adapter';
import { SorobanErrorMapper } from './utils/soroban-error.mapper';

Expand Down Expand Up @@ -388,6 +390,59 @@ export class SorobanAdapter implements OnchainAdapter {
}
}

async getTransactionStatus(
params: GetTransactionStatusParams,
): Promise<GetTransactionStatusResult> {
this.logger.debug('Getting transaction status for hash:', params.transactionHash);

try {
const _sdk = await this.loadSorobanSDK();
const client = await this.getRpcClient(); // eslint-disable-line @typescript-eslint/no-unsafe-assignment

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const response = await client.getTransaction(params.transactionHash);

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const rpcStatus = response?.status;

let mappedStatus: GetTransactionStatusResult['status'] = 'unknown';
if (rpcStatus === 'SUCCESS') {
mappedStatus = 'succeeded';
} else if (rpcStatus === 'FAILED') {
mappedStatus = 'failed';
} else if (rpcStatus === 'NOT_FOUND') {
mappedStatus = 'pending';
}

return {
transactionHash: params.transactionHash,
status: mappedStatus,
timestamp: new Date(),
details: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
rawResponse: response,
},
};
} catch (error) {
// If it's a timeout or network error, we can return 'unknown' or throw
// It's safer to map network errors to 'unknown' status rather than failing the poll request
// But we will let the error mapper handle it to keep consistency
const mappedError = this.errorMapper.mapError(error);
this.logger.error('Failed to get transaction status:', mappedError);

if (mappedError.statusCode >= 500) {
return {
transactionHash: params.transactionHash,
status: 'unknown',
timestamp: new Date(),
details: { error: mappedError },
};
}

throw error;
}
}

// Legacy method implementations
async createClaim(params: CreateClaimParams): Promise<CreateClaimResult> {
// Delegate to createAidPackage
Expand Down
90 changes: 90 additions & 0 deletions app/backend/src/onchain/transaction.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TransactionController } from './transaction.controller';
import { ONCHAIN_ADAPTER_TOKEN, OnchainAdapter } from './onchain.adapter';
import { InternalServerErrorException } from '@nestjs/common';

describe('TransactionController', () => {
let controller: TransactionController;
let mockOnchainAdapter: jest.Mocked<OnchainAdapter>;

beforeEach(async () => {
mockOnchainAdapter = {
getTransactionStatus: jest.fn(),
} as any;

const module: TestingModule = await Test.createTestingModule({
controllers: [TransactionController],
providers: [
{
provide: ONCHAIN_ADAPTER_TOKEN,
useValue: mockOnchainAdapter,
},
],
}).compile();

controller = module.get<TransactionController>(TransactionController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

describe('getTransactionStatus', () => {
it('should return pending status', async () => {
const mockResult = {
transactionHash: 'hash-123',
status: 'pending' as const,
timestamp: new Date(),
};
mockOnchainAdapter.getTransactionStatus.mockResolvedValue(mockResult);

const result = await controller.getTransactionStatus('hash-123');

expect(result).toEqual(mockResult);
expect(mockOnchainAdapter.getTransactionStatus).toHaveBeenCalledWith({
transactionHash: 'hash-123',
});
});

it('should return succeeded status', async () => {
const mockResult = {
transactionHash: 'hash-123',
status: 'succeeded' as const,
timestamp: new Date(),
};
mockOnchainAdapter.getTransactionStatus.mockResolvedValue(mockResult);

const result = await controller.getTransactionStatus('hash-123');

expect(result).toEqual(mockResult);
});

it('should return failed status', async () => {
const mockResult = {
transactionHash: 'hash-123',
status: 'failed' as const,
timestamp: new Date(),
};
mockOnchainAdapter.getTransactionStatus.mockResolvedValue(mockResult);

const result = await controller.getTransactionStatus('hash-123');

expect(result).toEqual(mockResult);
});

it('should handle timeout or unknown errors properly', async () => {
// Simulate timeout error thrown by adapter mapping
mockOnchainAdapter.getTransactionStatus.mockRejectedValue({
code: 'ETIMEDOUT',
message: 'Timeout',
});

// The error mapper will map this to a 504 status (which corresponds to InternalServerErrorException via the default fallback if not explicitly thrown as such)
// Actually, SorobanErrorMapper throws standard NestJS HttpExceptions based on its mapped status code.
// Wait, 504 isn't explicitly handled in throwMappedError (it handles 400,403,404,409,410,503), so it throws InternalServerErrorException.
await expect(controller.getTransactionStatus('hash-123')).rejects.toThrow(
InternalServerErrorException,
);
});
});
});
69 changes: 69 additions & 0 deletions app/backend/src/onchain/transaction.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
Controller,
Get,
Param,
HttpCode,
HttpStatus,
Logger,
Inject,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiOkResponse,
ApiInternalServerErrorResponse,
} from '@nestjs/swagger';
import { OnchainAdapter, ONCHAIN_ADAPTER_TOKEN } from './onchain.adapter';
import { SorobanErrorMapper } from './utils/soroban-error.mapper';

/**
* TransactionController
* REST API endpoints for querying Soroban transaction status
*/
@ApiTags('Onchain - Transactions')
@Controller('onchain/transactions')
export class TransactionController {
private readonly logger = new Logger(TransactionController.name);
private readonly errorMapper = new SorobanErrorMapper();

constructor(
@Inject(ONCHAIN_ADAPTER_TOKEN)
private readonly onchainAdapter: OnchainAdapter,
) {}

/**
* Get transaction status
* GET /onchain/transactions/:hash/status
*/
@Get(':hash/status')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get transaction status',
description:
'Polls the blockchain network to get the status of a specific transaction by its hash. Useful for clients to show progress while waiting for a transaction to complete.',
})
@ApiOkResponse({
description: 'Transaction status retrieved successfully.',
schema: {
example: {
transactionHash:
'ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD',
status: 'pending',
timestamp: '2026-03-30T12:30:00.000Z',
},
},
})
@ApiInternalServerErrorResponse({
description: 'Failed to retrieve transaction status.',
})
async getTransactionStatus(@Param('hash') hash: string): Promise<any> {
try {
return await this.onchainAdapter.getTransactionStatus({
transactionHash: hash,
});
} catch (error) {
this.logger.error(`Failed to get transaction status for hash ${hash}:`, error);
this.errorMapper.throwMappedError(error);
}
}
}
Loading
Loading