From 16cd758ae55e8b4a5e09637a72f37a5359cce8a0 Mon Sep 17 00:00:00 2001 From: noevidence1017 Date: Sun, 21 Jun 2026 15:27:11 +0100 Subject: [PATCH] fix(onchain): replace explicit any with proper types in onchain/soroban MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scopes the type-safety work to the onchain adapters, processor, services and Soroban error mapping (split out from the original 41-file PR per review). Does NOT flip the @typescript-eslint/no-explicit-any rule — that will land as its own one-line PR once the type refactors are merged. - Introduce a discriminated union for OnchainJobData/OnchainJobInput so the processor narrows params/result per operation type. - Replace duck-typed any error handling in soroban-error.mapper.ts with unknown plus a minimal SorobanErrorLike interface. - Validate the AidPackage status decoded from RPC against the known union (parsePackageStatus) instead of an unchecked `as` cast at the Soroban boundary. - Add a shared contract-value helper (toContractString/toStringRecord) to coerce unknown RPC values without [object Object] stringification, and fix the AidPackage.metadata Record mismatch. - Skip ledger-backfill entries with no resolvable campaignId (required on BalanceLedger) rather than failing the whole range. - Make AidEscrowController handlers and the onchain processor exhaustive so concrete return types type-check. Verified: tsc --noEmit clean, eslint clean (0 errors), onchain Jest suites pass. Co-Authored-By: Claude Opus 4.8 --- .../src/onchain/aid-escrow.controller.ts | 41 ++++++++---- .../interfaces/onchain-job.interface.ts | 19 ++++-- .../src/onchain/ledger-backfill.service.ts | 37 +++++++++- .../onchain/ledger-reconciliation.service.ts | 28 ++++++-- app/backend/src/onchain/onchain.adapter.ts | 14 ++-- app/backend/src/onchain/onchain.processor.ts | 24 ++++--- app/backend/src/onchain/onchain.service.ts | 25 ++++--- .../src/onchain/soroban-onchain.adapter.ts | 67 +++++++++++++------ app/backend/src/onchain/soroban.adapter.ts | 63 +++++++++-------- .../src/onchain/utils/contract-value.ts | 49 ++++++++++++++ .../src/onchain/utils/soroban-error.mapper.ts | 66 ++++++++++-------- 11 files changed, 309 insertions(+), 124 deletions(-) create mode 100644 app/backend/src/onchain/utils/contract-value.ts diff --git a/app/backend/src/onchain/aid-escrow.controller.ts b/app/backend/src/onchain/aid-escrow.controller.ts index a61abd41..e567ab9a 100644 --- a/app/backend/src/onchain/aid-escrow.controller.ts +++ b/app/backend/src/onchain/aid-escrow.controller.ts @@ -27,6 +27,15 @@ import { BatchCreateAidPackagesDto, } from './dto/aid-escrow.dto'; import { SorobanErrorMapper } from './utils/soroban-error.mapper'; +import { + CreateAidPackageResult, + BatchCreateAidPackagesResult, + ClaimAidPackageResult, + DisburseAidPackageResult, + GetAidPackageResult, + GetAidPackageCountResult, + GetTransactionStatusResult, +} from './onchain.adapter'; /** * AidEscrowController @@ -75,13 +84,13 @@ export class AidEscrowController { async createAidPackage( @Body() dto: CreateAidPackageDto, @Req() req: Request & { user?: { address?: string } }, - ): Promise { + ): Promise { try { const operatorAddress = req.user?.address || 'admin'; return await this.aidEscrowService.createAidPackage(dto, operatorAddress); } catch (error) { this.logger.error('Failed to create aid package:', error); - this.errorMapper.throwMappedError(error); + return this.errorMapper.throwMappedError(error); } } @@ -121,7 +130,7 @@ export class AidEscrowController { async batchCreateAidPackages( @Body() dto: BatchCreateAidPackagesDto, @Req() req: Request & { user?: { address?: string } }, - ): Promise { + ): Promise { if (dto.recipientAddresses.length !== dto.amounts.length) { throw new BadRequestException( 'Recipients and amounts arrays must have the same length', @@ -136,7 +145,7 @@ export class AidEscrowController { ); } catch (error) { this.logger.error('Failed to batch create aid packages:', error); - this.errorMapper.throwMappedError(error); + return this.errorMapper.throwMappedError(error); } } @@ -176,7 +185,7 @@ export class AidEscrowController { async claimAidPackage( @Param('id') packageId: string, @Req() req: Request & { user?: { address?: string } }, - ): Promise { + ): Promise { const recipientAddress = req.user?.address; if (!recipientAddress) { throw new BadRequestException('Recipient address required'); @@ -189,7 +198,7 @@ export class AidEscrowController { ); } catch (error) { this.logger.error('Failed to claim aid package:', error); - this.errorMapper.throwMappedError(error); + return this.errorMapper.throwMappedError(error); } } @@ -231,7 +240,7 @@ export class AidEscrowController { async disburseAidPackage( @Param('id') packageId: string, @Req() req: Request & { user?: { address?: string } }, - ): Promise { + ): Promise { try { const operatorAddress = req.user?.address || 'admin'; return await this.aidEscrowService.disburseAidPackage( @@ -240,7 +249,7 @@ export class AidEscrowController { ); } catch (error) { this.logger.error('Failed to disburse aid package:', error); - this.errorMapper.throwMappedError(error); + return this.errorMapper.throwMappedError(error); } } @@ -279,12 +288,14 @@ export class AidEscrowController { @ApiInternalServerErrorResponse({ description: 'Failed to retrieve package.', }) - async getAidPackage(@Param('id') packageId: string): Promise { + async getAidPackage( + @Param('id') packageId: string, + ): Promise { try { return await this.aidEscrowService.getAidPackage({ packageId }); } catch (error) { this.logger.error('Failed to get aid package:', error); - this.errorMapper.throwMappedError(error); + return this.errorMapper.throwMappedError(error); } } @@ -316,7 +327,7 @@ export class AidEscrowController { @ApiInternalServerErrorResponse({ description: 'Failed to retrieve statistics.', }) - async getAidPackageStats(): Promise { + async getAidPackageStats(): Promise { try { // For now, return aggregates for a default token // In production, this should be parameterized or determined from context @@ -327,7 +338,7 @@ export class AidEscrowController { }); } catch (error) { this.logger.error('Failed to get aid package stats:', error); - this.errorMapper.throwMappedError(error); + return this.errorMapper.throwMappedError(error); } } @@ -358,7 +369,9 @@ export class AidEscrowController { @ApiInternalServerErrorResponse({ description: 'Failed to retrieve transaction status.', }) - async getTransactionStatus(@Param('hash') hash: string): Promise { + async getTransactionStatus( + @Param('hash') hash: string, + ): Promise { if (!hash || hash.length < 10) { throw new BadRequestException('Invalid transaction hash'); } @@ -366,7 +379,7 @@ export class AidEscrowController { return await this.aidEscrowService.getTransactionStatus(hash); } catch (error) { this.logger.error('Failed to get transaction status:', error); - this.errorMapper.throwMappedError(error); + return this.errorMapper.throwMappedError(error); } } } diff --git a/app/backend/src/onchain/interfaces/onchain-job.interface.ts b/app/backend/src/onchain/interfaces/onchain-job.interface.ts index 1ad171d2..2dbef1e8 100644 --- a/app/backend/src/onchain/interfaces/onchain-job.interface.ts +++ b/app/backend/src/onchain/interfaces/onchain-job.interface.ts @@ -1,19 +1,28 @@ +import { + InitEscrowParams, + CreateClaimParams, + DisburseParams, +} from '../onchain.adapter'; + export enum OnchainOperationType { INIT_ESCROW = 'init-escrow', CREATE_CLAIM = 'create-claim', DISBURSE = 'disburse', } -export interface OnchainJobData { - type: OnchainOperationType; - params: any; +export type OnchainJobInput = + | { type: OnchainOperationType.INIT_ESCROW; params: InitEscrowParams } + | { type: OnchainOperationType.CREATE_CLAIM; params: CreateClaimParams } + | { type: OnchainOperationType.DISBURSE; params: DisburseParams }; + +export type OnchainJobData = OnchainJobInput & { timestamp: number; correlationId?: string; -} +}; export interface OnchainJobResult { success: boolean; transactionHash?: string; error?: string; - metadata?: Record; + metadata?: Record; } diff --git a/app/backend/src/onchain/ledger-backfill.service.ts b/app/backend/src/onchain/ledger-backfill.service.ts index b06a2eed..50b3bc67 100644 --- a/app/backend/src/onchain/ledger-backfill.service.ts +++ b/app/backend/src/onchain/ledger-backfill.service.ts @@ -19,6 +19,23 @@ export interface BackfillResult { totalCount: number; } +export interface BackfillLedgerEntry { + id: string; + campaignId?: string; + claimId?: string; + eventType: string; + amount: number; + note?: string; + createdAt: Date; +} + +interface BackfillProgressSnapshot { + startLedger?: number; + endLedger?: number; + processed?: number; + total?: number; +} + @Injectable() export class LedgerBackfillService { private readonly logger = new Logger(LedgerBackfillService.name); @@ -140,10 +157,21 @@ export class LedgerBackfillService { continue; } + // campaignId is required on BalanceLedger; skip entries we cannot + // attribute to a campaign rather than failing the whole range. + const resolvedCampaignId = entry.campaignId ?? campaignId; + if (!resolvedCampaignId) { + this.logger.warn( + `Skipping ledger entry ${entry.id}: no campaignId available`, + ); + skipped++; + continue; + } + await this.prisma.balanceLedger.create({ data: { id: entry.id, - campaignId: entry.campaignId || campaignId, + campaignId: resolvedCampaignId, claimId: entry.claimId, eventType: entry.eventType, amount: entry.amount, @@ -162,7 +190,10 @@ export class LedgerBackfillService { return { processed, skipped }; } - private fetchLedgerRange(_startLedger: number, _endLedger: number): any[] { + private fetchLedgerRange( + _startLedger: number, + _endLedger: number, + ): BackfillLedgerEntry[] { // Placeholder for actual Horizon API call // In production, this would query the Stellar Horizon API return []; @@ -176,7 +207,7 @@ export class LedgerBackfillService { } const state = await job.getState(); - const progress = job.progress as any; + const progress = job.progress as BackfillProgressSnapshot | undefined; return { jobId: job.id || 'unknown', diff --git a/app/backend/src/onchain/ledger-reconciliation.service.ts b/app/backend/src/onchain/ledger-reconciliation.service.ts index 8c5183af..293b32b2 100644 --- a/app/backend/src/onchain/ledger-reconciliation.service.ts +++ b/app/backend/src/onchain/ledger-reconciliation.service.ts @@ -13,11 +13,28 @@ export interface ReconciliationJobData { export interface ReconciliationDiscrepancy { ledger: number; type: 'missing' | 'amount_mismatch' | 'count_mismatch'; - expected: any; - observed: any; + expected: unknown; + observed: unknown; severity: 'low' | 'medium' | 'high'; } +export interface OnChainLedgerEntry { + id: string; + ledger: number; + amount: number; + eventType: string; +} + +interface ReconciliationProgressSnapshot { + startLedger?: number; + endLedger?: number; + totalLedgers?: number; + checkedLedgers?: number; + discrepancies?: ReconciliationDiscrepancy[]; + summary?: ReconciliationReport['summary']; + actionable?: boolean; +} + export interface ReconciliationReport { jobId: string; startLedger: number; @@ -198,7 +215,10 @@ export class LedgerReconciliationService { }; } - private fetchOnChainData(_startLedger: number, _endLedger: number): any[] { + private fetchOnChainData( + _startLedger: number, + _endLedger: number, + ): OnChainLedgerEntry[] { // Placeholder for actual Horizon API call // In production, this would query the Stellar Horizon API return []; @@ -231,7 +251,7 @@ export class LedgerReconciliationService { } const state = await job.getState(); - const progress = job.progress as any; + const progress = job.progress as ReconciliationProgressSnapshot | undefined; return { jobId: job.id || 'unknown', diff --git a/app/backend/src/onchain/onchain.adapter.ts b/app/backend/src/onchain/onchain.adapter.ts index 4a3b00c6..cff1305b 100644 --- a/app/backend/src/onchain/onchain.adapter.ts +++ b/app/backend/src/onchain/onchain.adapter.ts @@ -27,7 +27,7 @@ export interface InitEscrowResult { transactionHash: string; timestamp: Date; status: 'success' | 'failed'; - metadata?: Record; + metadata?: Record; } export interface CreateAidPackageParams { @@ -45,7 +45,7 @@ export interface CreateAidPackageResult { transactionHash: string; timestamp: Date; status: 'success' | 'failed'; - metadata?: Record; + metadata?: Record; } export interface BatchCreateAidPackagesParams { @@ -61,7 +61,7 @@ export interface BatchCreateAidPackagesResult { transactionHash: string; timestamp: Date; status: 'success' | 'failed'; - metadata?: Record; + metadata?: Record; } export interface ClaimAidPackageParams { @@ -75,7 +75,7 @@ export interface ClaimAidPackageResult { timestamp: Date; status: 'success' | 'failed'; amountClaimed: string; - metadata?: Record; + metadata?: Record; } export interface DisburseAidPackageParams { @@ -89,7 +89,7 @@ export interface DisburseAidPackageResult { timestamp: Date; status: 'success' | 'failed'; amountDisbursed: string; - metadata?: Record; + metadata?: Record; } export interface GetAidPackageParams { @@ -184,7 +184,7 @@ export interface CreateClaimResult { transactionHash: string; timestamp: Date; status: 'success' | 'failed'; - metadata?: Record; + metadata?: Record; } export interface DisburseParams { @@ -200,7 +200,7 @@ export interface DisburseResult { timestamp: Date; status: 'success' | 'failed'; amountDisbursed: string; - metadata?: Record; + metadata?: Record; } /** diff --git a/app/backend/src/onchain/onchain.processor.ts b/app/backend/src/onchain/onchain.processor.ts index 2d27cb66..956813d1 100644 --- a/app/backend/src/onchain/onchain.processor.ts +++ b/app/backend/src/onchain/onchain.processor.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; import { Logger, Inject } from '@nestjs/common'; import { Job } from 'bullmq'; @@ -7,7 +6,13 @@ import { OnchainJobResult, OnchainOperationType, } from './interfaces/onchain-job.interface'; -import { ONCHAIN_ADAPTER_TOKEN, OnchainAdapter } from './onchain.adapter'; +import { + ONCHAIN_ADAPTER_TOKEN, + OnchainAdapter, + InitEscrowResult, + CreateClaimResult, + DisburseResult, +} from './onchain.adapter'; import { DlqService } from '../jobs/dlq.service'; import { MetricsService } from '../observability/metrics/metrics.service'; @@ -41,7 +46,7 @@ export class OnchainProcessor extends WorkerHost { ); try { - let result: any; + let result: InitEscrowResult | CreateClaimResult | DisburseResult; switch (job.data.type) { case OnchainOperationType.INIT_ESCROW: result = await this.onchainAdapter.initEscrow(job.data.params); @@ -52,13 +57,16 @@ export class OnchainProcessor extends WorkerHost { case OnchainOperationType.DISBURSE: result = await this.onchainAdapter.disburse(job.data.params); break; - default: + default: { + // Exhaustive: every OnchainOperationType is handled above. + const unhandled: never = job.data; throw new Error( - `Unknown onchain operation type: ${String(job.data.type)}`, + `Unknown onchain operation type: ${String(unhandled)}`, ); + } } - if (result && 'status' in result && result.status === 'failed') { + if (result.status === 'failed') { throw new Error(`Onchain operation failed: ${String(job.data.type)}`); } @@ -70,8 +78,8 @@ export class OnchainProcessor extends WorkerHost { return { success: true, - transactionHash: result?.transactionHash, - metadata: result?.metadata, + transactionHash: result.transactionHash, + metadata: result.metadata, }; } catch (error) { const errMessage = diff --git a/app/backend/src/onchain/onchain.service.ts b/app/backend/src/onchain/onchain.service.ts index af57ac39..68adc325 100644 --- a/app/backend/src/onchain/onchain.service.ts +++ b/app/backend/src/onchain/onchain.service.ts @@ -3,6 +3,7 @@ import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { OnchainJobData, + OnchainJobInput, OnchainOperationType, } from './interfaces/onchain-job.interface'; import { LoggerService } from '../logger/logger.service'; @@ -39,7 +40,10 @@ export class OnchainService { ) {} async enqueueInitEscrow(params: InitEscrowJobParams) { - return this.enqueue(OnchainOperationType.INIT_ESCROW, params); + return this.enqueue({ + type: OnchainOperationType.INIT_ESCROW, + params, + }); } async enqueueCreateClaim(params: CreateClaimJobParams) { @@ -47,7 +51,10 @@ export class OnchainService { if (!params.tokenAddress) { throw new Error('tokenAddress is required for creating a claim'); } - return this.enqueue(OnchainOperationType.CREATE_CLAIM, params); + return this.enqueue({ + type: OnchainOperationType.CREATE_CLAIM, + params, + }); } async enqueueDisburse(params: DisburseJobParams) { @@ -55,18 +62,20 @@ export class OnchainService { if (!params.tokenAddress) { throw new Error('tokenAddress is required for disbursement'); } - return this.enqueue(OnchainOperationType.DISBURSE, params); + return this.enqueue({ + type: OnchainOperationType.DISBURSE, + params, + }); } - private async enqueue(type: OnchainOperationType, params: unknown) { + private async enqueue(input: OnchainJobInput) { const data: OnchainJobData = { - type, - params, + ...input, timestamp: Date.now(), correlationId: this.loggerService.getCorrelationId(), }; - const job = await this.onchainQueue.add(type, data, { + const job = await this.onchainQueue.add(input.type, data, { attempts: 5, backoff: { type: 'exponential', @@ -79,7 +88,7 @@ export class OnchainService { ? ` [correlationId=${data.correlationId}]` : ''; this.logger.log( - `Enqueued onchain job: ${job.id} for ${type}${correlationSuffix}`, + `Enqueued onchain job: ${job.id} for ${input.type}${correlationSuffix}`, ); return job; } diff --git a/app/backend/src/onchain/soroban-onchain.adapter.ts b/app/backend/src/onchain/soroban-onchain.adapter.ts index 61f29275..2fc3250a 100644 --- a/app/backend/src/onchain/soroban-onchain.adapter.ts +++ b/app/backend/src/onchain/soroban-onchain.adapter.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { OnchainAdapter, ONCHAIN_ADAPTER_TOKEN, + AidPackage, InitEscrowParams, InitEscrowResult, CreateAidPackageParams, @@ -33,6 +34,7 @@ import { GetTransactionStatusResult, TxStatus, } from './onchain.adapter'; +import { toContractString } from './utils/contract-value'; /** Calls the Soroban RPC endpoint and returns the result value. */ async function rpcCall( @@ -187,6 +189,25 @@ export class SorobanOnchainAdapter implements OnchainAdapter { }; } + /** + * Validates a raw status value from the RPC response against the known + * AidPackage status union. Falls back to 'Created' for any unexpected + * shape instead of trusting an unchecked cast at the contract boundary. + */ + private parsePackageStatus(status: unknown): AidPackage['status'] { + const known: AidPackage['status'][] = [ + 'Created', + 'Claimed', + 'Expired', + 'Cancelled', + 'Refunded', + ]; + if (typeof status === 'string' && (known as string[]).includes(status)) { + return status as AidPackage['status']; + } + return 'Created'; + } + async getAidPackage( params: GetAidPackageParams, ): Promise { @@ -194,14 +215,14 @@ export class SorobanOnchainAdapter implements OnchainAdapter { contractId: this.contractId, key: params.packageId, }); - const pkg = result as any; + const pkg = result as Record | null; return { package: { id: params.packageId, - recipient: pkg?.recipient ?? '', - amount: String(pkg?.amount ?? '0'), - token: pkg?.token ?? '', - status: pkg?.status ?? 'Created', + recipient: toContractString(pkg?.recipient), + amount: toContractString(pkg?.amount, '0'), + token: toContractString(pkg?.token), + status: this.parsePackageStatus(pkg?.status), createdAt: Number(pkg?.created_at ?? 0), expiresAt: Number(pkg?.expires_at ?? 0), }, @@ -216,12 +237,15 @@ export class SorobanOnchainAdapter implements OnchainAdapter { contractId: this.contractId, key: 'aggregates_' + params.token, }); - const agg = result as any; + const agg = result as Record | null; return { aggregates: { - totalCommitted: String(agg?.total_committed ?? '0'), - totalClaimed: String(agg?.total_claimed ?? '0'), - totalExpiredCancelled: String(agg?.total_expired_cancelled ?? '0'), + totalCommitted: toContractString(agg?.total_committed, '0'), + totalClaimed: toContractString(agg?.total_claimed, '0'), + totalExpiredCancelled: toContractString( + agg?.total_expired_cancelled, + '0', + ), }, timestamp: new Date(), }; @@ -237,7 +261,7 @@ export class SorobanOnchainAdapter implements OnchainAdapter { return { tokenAddress: params.tokenAddress, accountAddress: params.accountAddress, - balance: String((result as any) ?? '0'), + balance: toContractString(result, '0'), timestamp: new Date(), }; } @@ -247,9 +271,10 @@ export class SorobanOnchainAdapter implements OnchainAdapter { contractId: this.contractId, key: 'metadata', }); + const data = result as Record | null; return { - version: (result as any)?.version ?? '1.0.0', - name: (result as any)?.name ?? 'Soroban Contract', + version: toContractString(data?.version, '1.0.0'), + name: toContractString(data?.name, 'Soroban Contract'), timestamp: new Date(), }; } @@ -260,7 +285,7 @@ export class SorobanOnchainAdapter implements OnchainAdapter { key: 'paused', }); return { - isPaused: (result as any) ?? false, + isPaused: Boolean(result), timestamp: new Date(), }; } @@ -270,9 +295,10 @@ export class SorobanOnchainAdapter implements OnchainAdapter { contractId: this.contractId, key: 'fee_config', }); + const data = result as Record | null; return { - feePercentage: (result as any)?.fee_percentage ?? '0', - maxFee: (result as any)?.max_fee ?? '0', + feePercentage: toContractString(data?.fee_percentage, '0'), + maxFee: toContractString(data?.max_fee, '0'), timestamp: new Date(), }; } @@ -282,11 +308,12 @@ export class SorobanOnchainAdapter implements OnchainAdapter { contractId: this.contractId, key: 'summary_' + packageId, }); + const data = result as Record | null; return { packageId, - totalAmount: (result as any)?.total_amount ?? '0', - claimedAmount: (result as any)?.claimed_amount ?? '0', - status: (result as any)?.status ?? 'Active', + totalAmount: toContractString(data?.total_amount, '0'), + claimedAmount: toContractString(data?.claimed_amount, '0'), + status: toContractString(data?.status, 'Active'), timestamp: new Date(), }; } @@ -329,7 +356,7 @@ export class SorobanOnchainAdapter implements OnchainAdapter { const result = await rpcCall(this.http, this.rpcUrl, 'getTransaction', { hash, }); - const r = result as any; + const r = result as Record | null; let status: TxStatus; switch (r?.status) { case 'SUCCESS': @@ -351,7 +378,7 @@ export class SorobanOnchainAdapter implements OnchainAdapter { ledger: typeof r?.ledger === 'number' ? r.ledger : undefined, errorMessage: status === 'failed' - ? (r?.resultXdr ?? 'Transaction failed') + ? toContractString(r?.resultXdr, 'Transaction failed') : undefined, }; } catch { diff --git a/app/backend/src/onchain/soroban.adapter.ts b/app/backend/src/onchain/soroban.adapter.ts index 3d3a9836..19bc796f 100644 --- a/app/backend/src/onchain/soroban.adapter.ts +++ b/app/backend/src/onchain/soroban.adapter.ts @@ -43,6 +43,7 @@ import { } from './onchain.adapter'; import { SorobanErrorMapper } from './utils/soroban-error.mapper'; import { withRetryTimeout } from './utils/retry-with-timeout'; +import { toContractString, toStringRecord } from './utils/contract-value'; @Injectable() export class SorobanAdapter implements OnchainAdapter { @@ -135,7 +136,7 @@ export class SorobanAdapter implements OnchainAdapter { method: string, args: xdr.ScVal[], correlationId: string, - ): Promise<{ hash: string; result: any }> { + ): Promise<{ hash: string; result: unknown }> { const server = this.getServer(); const kp = this.getKeypair(); const contract = new Contract(this.contractId); @@ -230,7 +231,7 @@ export class SorobanAdapter implements OnchainAdapter { method: string, args: xdr.ScVal[], correlationId: string, - ): Promise { + ): Promise { const server = this.getServer(); const kp = this.getKeypair(); const contract = new Contract(this.contractId); @@ -275,16 +276,19 @@ export class SorobanAdapter implements OnchainAdapter { return null; } - private extractContractError(receipt: any): string { - if (receipt?.result?.retval) { - try { - const val = scValToNative(receipt.result.retval); - if (typeof val === 'object' && val !== null) { - return JSON.stringify(val); + private extractContractError(receipt: unknown): string { + if (receipt && typeof receipt === 'object' && 'result' in receipt) { + const result = (receipt as { result?: unknown }).result; + if (result && typeof result === 'object' && 'retval' in result) { + try { + const val = scValToNative((result as { retval: xdr.ScVal }).retval); + if (typeof val === 'object' && val !== null) { + return JSON.stringify(val); + } + return String(val); + } catch { + // fall through } - return String(val); - } catch { - // fall through } } return 'Contract transaction failed'; @@ -329,21 +333,22 @@ export class SorobanAdapter implements OnchainAdapter { return nativeToScVal(mapVal, { type: 'map' }); } - private parsePackage(scv: any): AidPackage | null { + private parsePackage(scv: unknown): AidPackage | null { if (!scv || typeof scv !== 'object') return null; + const obj = scv as Record; return { - id: String(scv.id ?? ''), - recipient: scv.recipient ?? '', - amount: String(scv.amount ?? '0'), - token: scv.token ?? '', - status: this.parseStatus(scv.status), - createdAt: Number(scv.created_at ?? 0), - expiresAt: Number(scv.expires_at ?? 0), - metadata: scv.metadata ?? undefined, + id: toContractString(obj.id), + recipient: toContractString(obj.recipient), + amount: toContractString(obj.amount, '0'), + token: toContractString(obj.token), + status: this.parseStatus(obj.status), + createdAt: Number(obj.created_at ?? 0), + expiresAt: Number(obj.expires_at ?? 0), + metadata: toStringRecord(obj.metadata), }; } - private parseStatus(status: any): AidPackage['status'] { + private parseStatus(status: unknown): AidPackage['status'] { if (typeof status === 'number') { const map: Record = { 0: 'Created', @@ -411,7 +416,7 @@ export class SorobanAdapter implements OnchainAdapter { ); return { - packageId: String(result ?? params.packageId), + packageId: toContractString(result, params.packageId), transactionHash: hash, timestamp: new Date(), status: 'success', @@ -556,17 +561,21 @@ export class SorobanAdapter implements OnchainAdapter { const cid = this.correlationId(); this.logger.log(`[${cid}] getAidPackageCount token=${params.token}`); - const result = await this.simulateReadOnly( + const raw = await this.simulateReadOnly( 'get_aggregates', [this.scvAddress(params.token)], cid, ); + const result = raw as Record | null; return { aggregates: { - totalCommitted: String(result?.total_committed ?? '0'), - totalClaimed: String(result?.total_claimed ?? '0'), - totalExpiredCancelled: String(result?.total_expired_cancelled ?? '0'), + totalCommitted: toContractString(result?.total_committed, '0'), + totalClaimed: toContractString(result?.total_claimed, '0'), + totalExpiredCancelled: toContractString( + result?.total_expired_cancelled, + '0', + ), }, timestamp: new Date(), }; @@ -622,7 +631,7 @@ export class SorobanAdapter implements OnchainAdapter { const version = await this.simulateReadOnly('get_version', [], cid); return { - version: String(version ?? '0'), + version: toContractString(version, '0'), name: 'Soroban AidEscrow Contract', timestamp: new Date(), }; diff --git a/app/backend/src/onchain/utils/contract-value.ts b/app/backend/src/onchain/utils/contract-value.ts new file mode 100644 index 00000000..bd26fb48 --- /dev/null +++ b/app/backend/src/onchain/utils/contract-value.ts @@ -0,0 +1,49 @@ +/** + * Helpers for coercing values decoded from Soroban contract / RPC responses. + * + * Those values arrive typed as `unknown`, so calling `String()` on them + * directly risks `[object Object]` output (and trips + * `@typescript-eslint/no-base-to-string`). These helpers only stringify + * primitive values and fall back otherwise. + */ + +/** + * Coerces an unknown contract value to a string, returning `fallback` for + * null/undefined or any non-primitive (object/array/function) value. + */ +export function toContractString(value: unknown, fallback = ''): string { + if (value === null || value === undefined) { + return fallback; + } + if (typeof value === 'string') { + return value; + } + if ( + typeof value === 'number' || + typeof value === 'bigint' || + typeof value === 'boolean' + ) { + return String(value); + } + return fallback; +} + +/** + * Coerces an unknown value into a `Record`, keeping only + * entries whose value is a string. Returns `undefined` when the input is not + * an object. + */ +export function toStringRecord( + value: unknown, +): Record | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + if (typeof val === 'string') { + out[key] = val; + } + } + return out; +} diff --git a/app/backend/src/onchain/utils/soroban-error.mapper.ts b/app/backend/src/onchain/utils/soroban-error.mapper.ts index c5d54562..720f996c 100644 --- a/app/backend/src/onchain/utils/soroban-error.mapper.ts +++ b/app/backend/src/onchain/utils/soroban-error.mapper.ts @@ -3,6 +3,22 @@ import { InternalServerErrorException, } from '@nestjs/common'; +/** + * Minimal shape covering the fields this mapper inspects across the + * various error sources it may receive (Node network errors, Axios + * errors, and Soroban SDK errors). + */ +interface SorobanErrorLike { + code?: string | number; + message?: string; + errorCode?: number; + response?: { + data?: { + error?: unknown; + }; + }; +} + /** * Maps Soroban contract errors to standardized backend error responses * Aligns with the global error handling strategy @@ -41,45 +57,39 @@ export class SorobanErrorMapper { /** * Maps a Soroban error to a backend-compatible error with HTTP status code */ - mapError(error: any): { + mapError(error: unknown): { statusCode: number; message: string; details?: Record; } { + const err = error as SorobanErrorLike | null | undefined; + // Handle RPC/Network errors - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (error?.code === 'ECONNREFUSED' || error?.code === 'ENOTFOUND') { + if (err?.code === 'ECONNREFUSED' || err?.code === 'ENOTFOUND') { return { statusCode: 503, message: 'Blockchain network unreachable', details: { error_type: 'network_error', - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - original_error: error?.message, + original_error: err?.message, }, }; } // Handle JSON-RPC errors (Soroban RPC Server responses) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (error?.response?.data?.error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment - const jsonRpcError = error.response.data.error; - return this.mapJsonRpcError(jsonRpcError); + if (err?.response?.data?.error) { + return this.mapJsonRpcError(err.response.data.error); } // Handle Soroban SDK errors with specific error codes - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (error?.errorCode !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const mapping = this.contractErrors[error.errorCode as number]; + if (err?.errorCode !== undefined) { + const mapping = this.contractErrors[err.errorCode]; if (mapping) { return { statusCode: mapping.code, message: mapping.message, details: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - error_code: error.errorCode, + error_code: err.errorCode, error_type: 'contract_error', }, }; @@ -87,8 +97,7 @@ export class SorobanErrorMapper { } // Handle contract invocation errors - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const message = error?.message as string | undefined; + const message = err?.message; if ( message && (message.includes('NotInitialized') || @@ -105,8 +114,7 @@ export class SorobanErrorMapper { } // Handle timeout errors - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (error?.code === 'ETIMEDOUT' || message?.includes('timeout')) { + if (err?.code === 'ETIMEDOUT' || message?.includes('timeout')) { return { statusCode: 504, message: 'Blockchain operation timed out', @@ -143,15 +151,17 @@ export class SorobanErrorMapper { /** * Maps JSON-RPC error responses (from Soroban RPC) */ - private mapJsonRpcError(jsonRpcError: any): { + private mapJsonRpcError(jsonRpcError: unknown): { statusCode: number; message: string; details?: Record; } { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const code = jsonRpcError.code; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const message = (jsonRpcError.message as string) || ''; + const rpcError = jsonRpcError as + | { code?: number; message?: string } + | null + | undefined; + const code = rpcError?.code ?? 0; + const message = rpcError?.message || ''; if (message.includes('Error(Contract')) { return this.mapContractErrorMessage(message); @@ -178,7 +188,7 @@ export class SorobanErrorMapper { return { statusCode: 500, message: 'Blockchain RPC internal error', - details: { error_code: code as number, rpc_message: message }, + details: { error_code: code, rpc_message: message }, }; default: @@ -187,13 +197,13 @@ export class SorobanErrorMapper { return { statusCode: 500, message: 'Blockchain RPC server error', - details: { error_code: code as number, rpc_message: message }, + details: { error_code: code, rpc_message: message }, }; } return { statusCode: 500, message: 'Blockchain RPC error', - details: { error_code: code as number, rpc_message: message }, + details: { error_code: code, rpc_message: message }, }; } }