From ae9a3f9d6e4d44be96ecf7511853323066f8b422 Mon Sep 17 00:00:00 2001 From: Noble Date: Sun, 21 Jun 2026 09:16:48 +0000 Subject: [PATCH 1/3] rework refund approvals --- .../soroban-errors/soroban-error.codes.ts | 126 +++--- .../soroban-errors/soroban-error.mapper.ts | 12 + app/backend/src/job-queue/handlers/index.ts | 1 + .../job-queue/handlers/refund-job.handler.ts | 359 ++++++++++++++++++ .../handlers/refund-job.handler.unit.spec.ts | 321 ++++++++++++++++ .../job-queue-initializer.service.ts | 16 + app/backend/src/job-queue/job-queue.module.ts | 10 + app/backend/src/job-queue/types/index.ts | 1 + .../src/job-queue/types/job-payloads.types.ts | 20 +- app/backend/src/job-queue/types/job.types.ts | 1 + app/backend/src/refunds/refunds.controller.ts | 12 +- app/backend/src/refunds/refunds.module.ts | 4 +- app/backend/src/refunds/refunds.service.ts | 43 ++- app/backend/src/refunds/refunds.types.ts | 9 +- ...0425000001_add_refund_onchain_tracking.sql | 34 ++ 15 files changed, 899 insertions(+), 70 deletions(-) create mode 100644 app/backend/src/job-queue/handlers/refund-job.handler.ts create mode 100644 app/backend/src/job-queue/handlers/refund-job.handler.unit.spec.ts create mode 100644 app/backend/supabase/migrations/20260425000001_add_refund_onchain_tracking.sql diff --git a/app/backend/src/common/soroban-errors/soroban-error.codes.ts b/app/backend/src/common/soroban-errors/soroban-error.codes.ts index 4c11311d6..2cdd89856 100644 --- a/app/backend/src/common/soroban-errors/soroban-error.codes.ts +++ b/app/backend/src/common/soroban-errors/soroban-error.codes.ts @@ -1,69 +1,75 @@ /** - * Stable API error codes for Soroban contract failures. - * - * These codes are part of the public API contract — clients can rely on them - * for deterministic error handling and UX messaging. - * - * Grouped by domain: Auth, Contract State, Balance, Admin, Version, Generic. - */ -export enum SorobanErrorCode { - // ── Auth ────────────────────────────────────────────────────────────────── - /** Caller is not authorised to perform this operation. */ - UNAUTHORIZED = 'CONTRACT_UNAUTHORIZED', - /** Required auth entry is missing from the transaction. */ - AUTH_MISSING = 'CONTRACT_AUTH_MISSING', + * Stable API error codes for Soroban contract failures. + * + * These codes are part of the public API contract — clients can rely on them + * for deterministic error handling and UX messaging. + * + * Grouped by domain: Auth, Contract State, Balance, Admin, Version, Generic. + */ + export enum SorobanErrorCode { + // ── Auth ────────────────────────────────────────────────────────────────── + /** Caller is not authorised to perform this operation. */ + UNAUTHORIZED = 'CONTRACT_UNAUTHORIZED', + /** Required auth entry is missing from the transaction. */ + AUTH_MISSING = 'CONTRACT_AUTH_MISSING', - // ── Contract state ──────────────────────────────────────────────────────── - /** Contract is paused; all mutating operations are blocked. */ - CONTRACT_PAUSED = 'CONTRACT_PAUSED', - /** Contract write operations are temporarily disabled by the server. */ - CONTRACT_WRITES_DISABLED = 'CONTRACT_WRITES_DISABLED', - /** Escrow / resource entry not found in contract storage. */ - ESCROW_NOT_FOUND = 'CONTRACT_ESCROW_NOT_FOUND', - /** Escrow has already been withdrawn or refunded. */ - ESCROW_ALREADY_SETTLED = 'CONTRACT_ESCROW_ALREADY_SETTLED', - /** Escrow has not yet expired; refund is not allowed. */ - ESCROW_NOT_EXPIRED = 'CONTRACT_ESCROW_NOT_EXPIRED', - /** Escrow has expired; withdrawal is no longer allowed. */ - ESCROW_EXPIRED = 'CONTRACT_ESCROW_EXPIRED', - /** Required contract storage entry is missing (MissingValue). */ - STORAGE_MISSING = 'CONTRACT_STORAGE_MISSING', - /** Ledger entry has expired and must be restored before use. */ - RESTORE_REQUIRED = 'CONTRACT_RESTORE_REQUIRED', + // ── Contract state ──────────────────────────────────────────────────────── + /** Contract is paused; all mutating operations are blocked. */ + CONTRACT_PAUSED = 'CONTRACT_PAUSED', + /** Contract write operations are temporarily disabled by the server. */ + CONTRACT_WRITES_DISABLED = 'CONTRACT_WRITES_DISABLED', + /** Escrow / resource entry not found in contract storage. */ + ESCROW_NOT_FOUND = 'CONTRACT_ESCROW_NOT_FOUND', + /** Escrow has already been withdrawn or refunded. */ + ESCROW_ALREADY_SETTLED = 'CONTRACT_ESCROW_ALREADY_SETTLED', + /** Escrow has not yet expired; refund is not allowed. */ + ESCROW_NOT_EXPIRED = 'CONTRACT_ESCROW_NOT_EXPIRED', + /** Escrow has expired; withdrawal is no longer allowed. */ + ESCROW_EXPIRED = 'CONTRACT_ESCROW_EXPIRED', + /** Required contract storage entry is missing (MissingValue). */ + STORAGE_MISSING = 'CONTRACT_STORAGE_MISSING', + /** Ledger entry has expired and must be restored before use. */ + RESTORE_REQUIRED = 'CONTRACT_RESTORE_REQUIRED', - // ── Balance ─────────────────────────────────────────────────────────────── - /** Account or escrow has insufficient token balance. */ - INSUFFICIENT_BALANCE = 'CONTRACT_INSUFFICIENT_BALANCE', - /** Amount provided is zero or negative. */ - INVALID_AMOUNT = 'CONTRACT_INVALID_AMOUNT', + // ── Balance ─────────────────────────────────────────────────────────────── + /** Account or escrow has insufficient token balance. */ + INSUFFICIENT_BALANCE = 'CONTRACT_INSUFFICIENT_BALANCE', + /** Amount provided is zero or negative. */ + INVALID_AMOUNT = 'CONTRACT_INVALID_AMOUNT', - // ── Version / upgrade ───────────────────────────────────────────────────── - /** Contract schema version is not supported by this client. */ - VERSION_MISMATCH = 'CONTRACT_VERSION_MISMATCH', - /** WASM hash provided for upgrade is invalid. */ - INVALID_WASM_HASH = 'CONTRACT_INVALID_WASM_HASH', + // ── Version / upgrade ───────────────────────────────────────────────────── + /** Contract schema version is not supported by this client. */ + VERSION_MISMATCH = 'CONTRACT_VERSION_MISMATCH', + /** WASM hash provided for upgrade is invalid. */ + INVALID_WASM_HASH = 'CONTRACT_INVALID_WASM_HASH', - // ── Admin ───────────────────────────────────────────────────────────────── - /** Caller is not the contract admin. */ - NOT_ADMIN = 'CONTRACT_NOT_ADMIN', - /** Admin address provided is invalid. */ - INVALID_ADMIN = 'CONTRACT_INVALID_ADMIN', + // ── Admin ───────────────────────────────────────────────────────────────── + /** Caller is not the contract admin. */ + NOT_ADMIN = 'CONTRACT_NOT_ADMIN', + /** Admin address provided is invalid. */ + INVALID_ADMIN = 'CONTRACT_INVALID_ADMIN', - // ── Input / params ──────────────────────────────────────────────────────── - /** One or more input values are invalid for this contract call. */ - INVALID_INPUT = 'CONTRACT_INVALID_INPUT', - /** Contract or account does not exist on the network. */ - NOT_FOUND = 'CONTRACT_NOT_FOUND', + // ── Input / params ──────────────────────────────────────────────────────── + /** One or more input values are invalid for this contract call. */ + INVALID_INPUT = 'CONTRACT_INVALID_INPUT', + /** Contract or account does not exist on the network. */ + NOT_FOUND = 'CONTRACT_NOT_FOUND', - // ── Resource limits ─────────────────────────────────────────────────────── - /** Transaction exceeds Soroban compute budget. */ - BUDGET_EXCEEDED = 'CONTRACT_BUDGET_EXCEEDED', + // ── Resource limits ─────────────────────────────────────────────────────── + /** Transaction exceeds Soroban compute budget. */ + BUDGET_EXCEEDED = 'CONTRACT_BUDGET_EXCEEDED', - // ── Generic fallback ────────────────────────────────────────────────────── - /** An unexpected contract error occurred. */ - UNKNOWN = 'CONTRACT_UNKNOWN_ERROR', + // ── Generic fallback ────────────────────────────────────────────────────── + /** An unexpected contract error occurred. */ + UNKNOWN = 'CONTRACT_UNKNOWN_ERROR', - // ── Indexer Lag Guard ───────────────────────────────────────────────────── - /** Indexer is lagging too far behind the network; risky operations are blocked. */ - INDEXER_LAGGING = 'INDEXER_LAGGING', -} + // ── Indexer Lag Guard ───────────────────────────────────────────────────── + /** Indexer is lagging too far behind the network; risky operations are blocked. */ + INDEXER_LAGGING = 'INDEXER_LAGGING', + + // ── Refund specific ─────────────────────────────────────────────────────── + /** Refund operation failed due to contract error. */ + REFUND_FAILED = 'CONTRACT_REFUND_FAILED', + /** Refund already executed on-chain. */ + REFUND_DUPLICATE = 'CONTRACT_REFUND_DUPLICATE', + } diff --git a/app/backend/src/common/soroban-errors/soroban-error.mapper.ts b/app/backend/src/common/soroban-errors/soroban-error.mapper.ts index 0cafa1d3d..6adf23abd 100644 --- a/app/backend/src/common/soroban-errors/soroban-error.mapper.ts +++ b/app/backend/src/common/soroban-errors/soroban-error.mapper.ts @@ -97,6 +97,18 @@ const ERROR_MAPPINGS: Array<{ message: 'The escrow has expired and can no longer be withdrawn.', }, + // ── Refund specific ─────────────────────────────────────────────────────── + { + pattern: /refund.*failed|refund.*error/i, + code: SorobanErrorCode.REFUND_FAILED, + message: 'The refund operation failed. Please check the refund eligibility and try again.', + }, + { + pattern: /refund.*duplicate|already.*refunded|refund.*exists/i, + code: SorobanErrorCode.REFUND_DUPLICATE, + message: 'This refund has already been processed on-chain.', + }, + // ── Storage ─────────────────────────────────────────────────────────────── { pattern: /restore.*required|entry.*expired.*restore/i, diff --git a/app/backend/src/job-queue/handlers/index.ts b/app/backend/src/job-queue/handlers/index.ts index 13114b9f0..4ad7ccf50 100644 --- a/app/backend/src/job-queue/handlers/index.ts +++ b/app/backend/src/job-queue/handlers/index.ts @@ -9,3 +9,4 @@ export { RecurringPaymentHandler } from './recurring-payment.handler'; export { ExportGenerationHandler } from './export-generation.handler'; export { ReconciliationHandler } from './reconciliation.handler'; export { StellarReconnectHandler } from './stellar-reconnect.handler'; +export { RefundJobHandler, PermanentRefundError } from './refund-job.handler'; diff --git a/app/backend/src/job-queue/handlers/refund-job.handler.ts b/app/backend/src/job-queue/handlers/refund-job.handler.ts new file mode 100644 index 000000000..c401362ba --- /dev/null +++ b/app/backend/src/job-queue/handlers/refund-job.handler.ts @@ -0,0 +1,359 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { TransactionBuilder, Contract, nativeToScVal, TimeoutInfinite } from '@stellar/stellar-sdk'; + +import { JobHandler, Job, CancellationToken } from '../types'; +import { RefundJobPayload } from '../types/job-payloads.types'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { ContractRegistryService } from '../../contracts/contract-registry.service'; +import { SorobanRpcService, SOROBAN_ERROR_CODES } from '../../transactions/soroban-rpc.service'; +import { StellarSigningService } from '../../common/stellar-signing.service'; +import { SorobanErrorCode } from '../../common/soroban-errors'; +import { RefundableEntityType } from '../../refunds/refunds.types'; + +@Injectable() +export class RefundJobHandler implements JobHandler { + private readonly logger = new Logger(RefundJobHandler.name); + private readonly networkPassphrase: string; + + constructor( + private readonly supabaseService: SupabaseService, + private readonly contractRegistry: ContractRegistryService, + private readonly sorobanRpc: SorobanRpcService, + private readonly signingService: StellarSigningService, + ) { + this.networkPassphrase = process.env.NETWORK === 'mainnet' + ? 'Public Global Stellar Network ; September 2015' + : 'Test SDF Network ; September 2015'; + } + + async execute(job: Job, cancellationToken: CancellationToken): Promise { + const { refundId, entityType, entityId } = job.payload; + + cancellationToken.throwIfCancelled(); + + this.logger.log( + `Processing refund job: refundId=${refundId}, entityType=${entityType}, entityId=${entityId}, jobId=${job.id}`, + ); + + const client = this.supabaseService.getClient(); + + let existingRecord: { + on_chain_tx_hash: string | null; + contract_id: string | null; + retry_count: number; + is_retryable: boolean; + } | null; + + try { + const { data } = await client + .from('refund_attempts') + .select('on_chain_tx_hash, contract_id, retry_count, is_retryable') + .eq('id', refundId) + .maybeSingle(); + + existingRecord = data; + + if (existingRecord?.on_chain_tx_hash) { + this.logger.log( + `Refund ${refundId} already has transaction hash ${existingRecord.on_chain_tx_hash}, checking status...`, + ); + + const txStatus = await this.sorobanRpc.pollTransactionStatus(existingRecord.on_chain_tx_hash); + + if (txStatus.status === 'SUCCESS') { + await this.updateRefundStatus(refundId, 'confirmed', { + onChainTxHash: existingRecord.on_chain_tx_hash, + contractId: existingRecord.contract_id ?? undefined, + }); + return; + } + + if (txStatus.status === 'FAILED') { + throw new Error(`Previous submission failed: ${txStatus.errorMessage ?? 'Unknown error'}`); + } + + throw new Error('Previous transaction not yet confirmed, job will retry'); + } + + const registry = await this.contractRegistry.getRegistry(); + const contractId = registry.data.RustAcademy?.id; + + if (!contractId) { + throw new Error('RustAcademy contract not found in registry'); + } + + const entityInfo = await this.getEntityInfo(entityType as RefundableEntityType, entityId); + + cancellationToken.throwIfCancelled(); + + const contract = new Contract(contractId); + + const sourceAccount = await this.sorobanRpc.getAccount(this.signingService.publicKey); + + const refundArgs = [ + nativeToScVal(Buffer.from(entityInfo.commitment, 'hex'), { type: 'bytes' }), + nativeToScVal(this.signingService.publicKey, { type: 'address' }), + ]; + + const txBuilder = new TransactionBuilder(sourceAccount, { + fee: '100000', + networkPassphrase: this.networkPassphrase, + }); + + txBuilder.addOperation(contract.call('refund', ...refundArgs)); + txBuilder.setTimeout(TimeoutInfinite); + + const builtTx = txBuilder.build(); + + const preparedTx = await this.sorobanRpc.prepareTransaction(builtTx); + this.signingService.signTransaction(preparedTx, this.networkPassphrase); + + const txHash = preparedTx.hash().toString('hex'); + + const result = await this.sorobanRpc.submitAndWait(preparedTx); + + if (result.status === 'SUCCESS') { + await this.updateRefundStatus(refundId, 'confirmed', { + onChainTxHash: txHash, + contractId, + network: this.networkPassphrase.includes('PUBLIC') ? 'mainnet' : 'testnet', + }); + + this.logger.log( + `Refund successfully submitted and confirmed: refundId=${refundId}, txHash=${txHash}, jobId=${job.id}`, + ); + return; + } + + const failureReason = this.classifyFailure(result); + const isRetryable = this.isFailureRetryable(failureReason); + + await this.updateRefundStatus(refundId, 'failed', { + onChainTxHash: txHash, + contractId, + failureReason, + isRetryable, + network: this.networkPassphrase.includes('PUBLIC') ? 'mainnet' : 'testnet', + }); + + if (!isRetryable) { + throw new PermanentRefundError(failureReason); + } + + throw new Error(failureReason); + } catch (error) { + if (error instanceof PermanentRefundError) { + throw error; + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `Refund job failed: refundId=${refundId}, jobId=${job.id}, error=${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ); + + if (existingRecord) { + const txHash = existingRecord.on_chain_tx_hash; + const contractId = existingRecord.contract_id ?? undefined; + const failureReason = this.sanitizeFailureReason(errorMessage); + + await this.updateRefundStatus(refundId, 'failed', { + onChainTxHash: txHash ?? undefined, + contractId, + failureReason, + isRetryable: true, + }); + } + + throw error; + } + } + + async validate(payload: RefundJobPayload): Promise { + const errors: string[] = []; + + if (!payload.refundId || typeof payload.refundId !== 'string') { + errors.push('refundId is required and must be a string'); + } + + if (!payload.idempotencyKey || typeof payload.idempotencyKey !== 'string') { + errors.push('idempotencyKey is required and must be a string'); + } + + if (!payload.entityType || typeof payload.entityType !== 'string') { + errors.push('entityType is required and must be a string'); + } + + if (!payload.entityId || typeof payload.entityId !== 'string') { + errors.push('entityId is required and must be a string'); + } + + if (errors.length > 0) { + throw new PermanentRefundError(`Validation failed: ${errors.join(', ')}`); + } + } + + async onFailure(job: Job, error: Error): Promise { + const { refundId } = job.payload; + + this.logger.error( + `Refund permanently failed: refundId=${refundId}, jobId=${job.id}, error=${error.message}`, + ); + + await this.updateRefundStatus(refundId, 'failed', { + failureReason: error.message, + isRetryable: false, + }); + } + + private async getEntityInfo( + entityType: RefundableEntityType, + entityId: string, + ): Promise<{ commitment: string }> { + const client = this.supabaseService.getClient(); + + if (entityType === 'escrow') { + const { data: escrow } = await client + .from('escrow_records') + .select('commitment') + .eq('id', entityId) + .maybeSingle(); + + if (!escrow?.commitment) { + throw new Error(`Escrow ${entityId} not found or missing commitment`); + } + + return { commitment: escrow.commitment }; + } + + if (entityType === 'payment') { + const { data: payment } = await client + .from('payment_records') + .select('commitment') + .eq('id', entityId) + .maybeSingle(); + + if (!payment?.commitment) { + throw new Error(`Payment ${entityId} not found or missing commitment`); + } + + return { commitment: payment.commitment }; + } + + if (entityType === 'link') { + const { data: link } = await client + .from('payment_links') + .select('commitment') + .eq('id', entityId) + .maybeSingle(); + + if (!link?.commitment) { + throw new Error(`Link ${entityId} not found or missing commitment`); + } + + return { commitment: link.commitment }; + } + + throw new Error(`Unknown entity type: ${entityType}`); + } + + private async updateRefundStatus( + refundId: string, + status: RefundStatus, + options: { + onChainTxHash?: string; + contractId?: string; + failureReason?: string; + isRetryable?: boolean; + network?: string; + } = {}, + ): Promise { + const client = this.supabaseService.getClient(); + + const update: Record = { + status, + updated_at: new Date().toISOString(), + }; + + if (options.onChainTxHash) { + update.on_chain_tx_hash = options.onChainTxHash; + } + + if (options.contractId) { + update.contract_id = options.contractId; + } + + if (options.failureReason) { + update.failure_reason = options.failureReason; + } + + if (options.isRetryable !== undefined) { + update.is_retryable = options.isRetryable; + } + + if (options.network) { + update.network = options.network; + } + + const { error } = await client + .from('refund_attempts') + .update(update) + .eq('id', refundId); + + if (error) { + this.logger.error(`Failed to update refund status: ${error.message}`); + } + } + + private classifyFailure( + txResult: { status: string; errorCode?: string; errorMessage?: string }, + ): string { + if (txResult.status === 'FAILED') { + if (txResult.errorCode === SOROBAN_ERROR_CODES.CONTRACT_ERROR) { + return txResult.errorMessage ?? SorobanErrorCode.REFUND_FAILED; + } + return txResult.errorMessage ?? SorobanErrorCode.UNKNOWN; + } + return `${SOROBAN_ERROR_CODES.TX_TIMEOUT}: Transaction not confirmed`; + } + + private isFailureRetryable(errorMessage: string): boolean { + const lowerMessage = errorMessage.toLowerCase(); + + const permanentPatterns = [ + SorobanErrorCode.ESCROW_ALREADY_SETTLED.toLowerCase(), + SorobanErrorCode.ESCROW_NOT_EXPIRED.toLowerCase(), + SorobanErrorCode.INVALID_INPUT.toLowerCase(), + ]; + + for (const pattern of permanentPatterns) { + if (lowerMessage.includes(pattern)) { + return false; + } + } + + return true; + } + + private sanitizeFailureReason(message: string): string { + const dangerousPatterns = [ + /secret/i, + /private.?key/i, + /seed/i, + ]; + + let sanitized = message; + for (const pattern of dangerousPatterns) { + sanitized = sanitized.replace(pattern, '[REDACTED]'); + } + + return sanitized; + } +} + +export class PermanentRefundError extends Error { + constructor(message: string) { + super(message); + this.name = 'PermanentRefundError'; + } +} \ No newline at end of file diff --git a/app/backend/src/job-queue/handlers/refund-job.handler.unit.spec.ts b/app/backend/src/job-queue/handlers/refund-job.handler.unit.spec.ts new file mode 100644 index 000000000..beb7605fa --- /dev/null +++ b/app/backend/src/job-queue/handlers/refund-job.handler.unit.spec.ts @@ -0,0 +1,321 @@ +/** + * Refund Job Handler - Unit Tests + * + * Tests for the RefundJobHandler that processes on-chain refund operations. + * + * **Validates: BE-12 refund workflow** + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { RefundJobHandler, PermanentRefundError } from './refund-job.handler'; +import { Job, JobStatus, JobType, CancellationToken } from '../types'; +import { RefundJobPayload } from '../types/job-payloads.types'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { ContractRegistryService } from '../../contracts/contract-registry.service'; +import { SorobanRpcService } from '../../transactions/soroban-rpc.service'; +import { StellarSigningService } from '../../common/stellar-signing.service'; + +describe('RefundJobHandler', () => { + let handler: RefundJobHandler; + let supabaseService: jest.Mocked; + let sorobanRpc: jest.Mocked; + + const mockPayload: RefundJobPayload = { + refundId: 'refund-123', + idempotencyKey: 'idem-key-456', + entityType: 'escrow', + entityId: 'escrow-789', + }; + + const mockJob: Job = { + id: 'job-123', + type: JobType.REFUND, + payload: mockPayload, + status: JobStatus.PENDING, + attempts: 0, + maxAttempts: 5, + createdAt: new Date(), + scheduledAt: new Date(), + startedAt: null, + completedAt: null, + failureReason: null, + visibilityTimeout: null, + }; + + const mockCancellationToken: CancellationToken = { + isCancelled: jest.fn().mockReturnValue(false), + throwIfCancelled: jest.fn(), + }; + + beforeEach(async () => { + const mockSupabaseClient = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn(), + update: jest.fn().mockReturnThis(), + }; + + const mockSupabaseService = { + getClient: jest.fn().mockReturnValue(mockSupabaseClient), + }; + + const mockRegistry = { + data: { + RustAcademy: { id: 'CCD123456789ABCDEF' }, + }, + }; + + const mockContractRegistry = { + getRegistry: jest.fn().mockResolvedValue(mockRegistry), + }; + + const mockSorobanRpc = { + getAccount: jest.fn().mockResolvedValue({ + accountId: 'G123456789', + sequenceNumber: '123', + publicKey: jest.fn().mockReturnValue('G123456789'), + }), + prepareTransaction: jest.fn().mockImplementation((tx) => tx), + submitAndWait: jest.fn(), + pollTransactionStatus: jest.fn(), + }; + + const mockSigningService = { + publicKey: 'G123456789', + isConfigured: true, + signTransaction: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RefundJobHandler, + { provide: SupabaseService, useValue: mockSupabaseService }, + { provide: ContractRegistryService, useValue: mockContractRegistry }, + { provide: SorobanRpcService, useValue: mockSorobanRpc }, + { provide: StellarSigningService, useValue: mockSigningService }, + ], + }).compile(); + + handler = module.get(RefundJobHandler); + supabaseService = module.get(SupabaseService) as jest.Mocked; + sorobanRpc = module.get(SorobanRpcService) as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('validate', () => { + it('should pass validation with valid payload', async () => { + await expect(handler.validate(mockPayload)).resolves.not.toThrow(); + }); + + it('should throw PermanentRefundError for missing refundId', async () => { + const invalidPayload = { ...mockPayload, refundId: '' }; + + await expect(handler.validate(invalidPayload)).rejects.toThrow(PermanentRefundError); + await expect(handler.validate(invalidPayload)).rejects.toThrow('refundId is required'); + }); + + it('should throw PermanentRefundError for missing idempotencyKey', async () => { + const invalidPayload = { ...mockPayload, idempotencyKey: '' }; + + await expect(handler.validate(invalidPayload)).rejects.toThrow(PermanentRefundError); + await expect(handler.validate(invalidPayload)).rejects.toThrow('idempotencyKey is required'); + }); + + it('should throw PermanentRefundError for missing entityType', async () => { + const invalidPayload = { ...mockPayload, entityType: '' as unknown as RefundJobPayload['entityType'] }; + + await expect(handler.validate(invalidPayload)).rejects.toThrow(PermanentRefundError); + await expect(handler.validate(invalidPayload)).rejects.toThrow('entityType is required'); + }); + + it('should throw PermanentRefundError for missing entityId', async () => { + const invalidPayload = { ...mockPayload, entityId: '' }; + + await expect(handler.validate(invalidPayload)).rejects.toThrow(PermanentRefundError); + await expect(handler.validate(invalidPayload)).rejects.toThrow('entityId is required'); + }); + }); + + describe('execute', () => { + it('should process refund successfully when transaction succeeds', async () => { + const mockClient = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockResolvedValue({ data: null }), + update: jest.fn().mockReturnThis(), + }; + + // Override the client returned by supabaseService + (supabaseService.getClient as jest.Mock).mockReturnValue(mockClient); + supabaseService.getClient.mockReturnValue(mockClient); + + sorobanRpc.submitAndWait.mockResolvedValue({ + status: 'SUCCESS', + hash: 'tx-hash-abc123', + }); + + // Mock getEntityInfo to return commitment + const existingRecordMock = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockResolvedValue({ + data: { commitment: 'deadbeef'.repeat(8) }, + }), + }; + mockClient.from.mockImplementation((table: string) => { + if (table === 'refund_attempts') { + return mockClient; + } + if (table === 'escrow_records') { + return existingRecordMock; + } + return mockClient; + }); + + await handler.execute(mockJob, mockCancellationToken); + + expect(sorobanRpc.submitAndWait).toHaveBeenCalled(); + expect(mockClient.update).toHaveBeenCalled(); + }); + + it('should update refund as failed when transaction fails', async () => { + const mockClient = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockResolvedValue({ data: null }), + update: jest.fn().mockReturnThis(), + }; + + (supabaseService.getClient as jest.Mock).mockReturnValue(mockClient); + supabaseService.getClient.mockReturnValue(mockClient); + + sorobanRpc.submitAndWait.mockResolvedValue({ + status: 'FAILED', + hash: 'tx-hash-abc123', + errorCode: 'SOROBAN_CONTRACT_ERROR', + errorMessage: 'Contract execution failed', + }); + + // Mock getEntityInfo to return commitment + const existingRecordMock = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockResolvedValue({ + data: { commitment: 'deadbeef'.repeat(8) }, + }), + }; + mockClient.from.mockImplementation((table: string) => { + if (table === 'refund_attempts') { + return mockClient; + } + if (table === 'escrow_records') { + return existingRecordMock; + } + return mockClient; + }); + + await expect(handler.execute(mockJob, mockCancellationToken)).rejects.toThrow(); + + expect(sorobanRpc.submitAndWait).toHaveBeenCalled(); + }); + + it('should throw PermanentRefundError for non-retryable failures', async () => { + const mockClient = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockResolvedValue({ data: null }), + update: jest.fn().mockReturnThis(), + }; + + (supabaseService.getClient as jest.Mock).mockReturnValue(mockClient); + supabaseService.getClient.mockReturnValue(mockClient); + + sorobanRpc.submitAndWait.mockResolvedValue({ + status: 'FAILED', + hash: 'tx-hash-abc123', + errorCode: 'SOROBAN_CONTRACT_ERROR', + errorMessage: 'contract_escrow_already_settled', + }); + + // Mock getEntityInfo to return commitment + const existingRecordMock = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockResolvedValue({ + data: { commitment: 'deadbeef'.repeat(8) }, + }), + }; + mockClient.from.mockImplementation((table: string) => { + if (table === 'refund_attempts') { + return mockClient; + } + if (table === 'escrow_records') { + return existingRecordMock; + } + return mockClient; + }); + + await expect(handler.execute(mockJob, mockCancellationToken)).rejects.toThrow(PermanentRefundError); + }); + + it('should check existing transaction hash before submitting', async () => { + const mockClient = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockResolvedValue({ + data: { + on_chain_tx_hash: 'existing-tx-hash', + contract_id: 'CCD123', + retry_count: 0, + is_retryable: true, + }, + }), + update: jest.fn().mockReturnThis(), + }; + + (supabaseService.getClient as jest.Mock).mockReturnValue(mockClient); + supabaseService.getClient.mockReturnValue(mockClient); + + sorobanRpc.pollTransactionStatus.mockResolvedValue({ + status: 'SUCCESS', + hash: 'existing-tx-hash', + }); + + await handler.execute(mockJob, mockCancellationToken); + + expect(sorobanRpc.pollTransactionStatus).toHaveBeenCalledWith('existing-tx-hash'); + expect(sorobanRpc.submitAndWait).not.toHaveBeenCalled(); + }); + }); + + describe('onFailure', () => { + it('should update refund status to failed with is_retryable false', async () => { + const mockClient = { + from: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + }; + (supabaseService.getClient as jest.Mock).mockReturnValue(mockClient); + + await handler.onFailure(mockJob, new Error('Permanent failure')); + + expect(mockClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + is_retryable: false, + }) + ); + }); + }); +}); \ No newline at end of file diff --git a/app/backend/src/job-queue/job-queue-initializer.service.ts b/app/backend/src/job-queue/job-queue-initializer.service.ts index 20cfcbafb..3d57849ff 100644 --- a/app/backend/src/job-queue/job-queue-initializer.service.ts +++ b/app/backend/src/job-queue/job-queue-initializer.service.ts @@ -17,6 +17,7 @@ import { ExportGenerationHandler, ReconciliationHandler, StellarReconnectHandler, + RefundJobHandler, } from './handlers'; /** @@ -37,6 +38,7 @@ export class JobQueueInitializer implements OnModuleInit { private readonly exportGenerationHandler: ExportGenerationHandler, private readonly reconciliationHandler: ReconciliationHandler, private readonly stellarReconnectHandler: StellarReconnectHandler, + private readonly refundJobHandler: RefundJobHandler, ) {} /** @@ -120,6 +122,20 @@ export class JobQueueInitializer implements OnModuleInit { }, ); + // Register refund handler + // Requirements: BE-12 refund workflow + this.registry.registerHandler( + JobType.REFUND, + this.refundJobHandler, + { + maxAttempts: 5, + backoffStrategy: 'exponential', + initialDelayMs: 60000, // 1 minute + maxDelayMs: 3600000, // 1 hour + visibilityTimeoutMs: 600000, // 10 minutes + }, + ); + this.logger.log('Job handler registration complete'); } } diff --git a/app/backend/src/job-queue/job-queue.module.ts b/app/backend/src/job-queue/job-queue.module.ts index 43f37657e..3e3febfdd 100644 --- a/app/backend/src/job-queue/job-queue.module.ts +++ b/app/backend/src/job-queue/job-queue.module.ts @@ -22,12 +22,16 @@ import { IngestionModule } from "../ingestion/ingestion.module"; import { AuthModule } from "../auth/auth.module"; import { MetricsModule } from "../metrics/metrics.module"; import { ApiKeysModule } from "../api-keys/api-keys.module"; +import { ContractsModule } from "../contracts/contracts.module"; +import { TransactionsModule } from "../transactions/transactions.module"; +import { StellarModule } from "../stellar/stellar.module"; import { WebhookDeliveryHandler, RecurringPaymentHandler, ExportGenerationHandler, ReconciliationHandler, StellarReconnectHandler, + RefundJobHandler, } from "./handlers"; /** @@ -47,6 +51,7 @@ import { * - ExportGenerationHandler: Handler for export generation jobs * - ReconciliationHandler: Handler for reconciliation jobs * - StellarReconnectHandler: Handler for Stellar SSE reconnection jobs + * - RefundJobHandler: Handler for on-chain refund operations */ @Module({ imports: [ @@ -54,6 +59,9 @@ import { AuthModule, MetricsModule, ApiKeysModule, + ContractsModule, + TransactionsModule, + StellarModule, forwardRef(() => NotificationsModule), forwardRef(() => LinksModule), forwardRef(() => ReconciliationModule), @@ -73,6 +81,7 @@ import { ExportGenerationHandler, ReconciliationHandler, StellarReconnectHandler, + RefundJobHandler, ], exports: [ JobQueueService, @@ -84,6 +93,7 @@ import { ExportGenerationHandler, ReconciliationHandler, StellarReconnectHandler, + RefundJobHandler, ], }) export class JobQueueModule {} diff --git a/app/backend/src/job-queue/types/index.ts b/app/backend/src/job-queue/types/index.ts index 88e6ffc33..5628133c2 100644 --- a/app/backend/src/job-queue/types/index.ts +++ b/app/backend/src/job-queue/types/index.ts @@ -21,4 +21,5 @@ export { ExportGenerationPayload, ReconciliationPayload, StellarReconnectPayload, + RefundJobPayload, } from './job-payloads.types'; diff --git a/app/backend/src/job-queue/types/job-payloads.types.ts b/app/backend/src/job-queue/types/job-payloads.types.ts index d38c1c2e1..d76e533f0 100644 --- a/app/backend/src/job-queue/types/job-payloads.types.ts +++ b/app/backend/src/job-queue/types/job-payloads.types.ts @@ -99,7 +99,25 @@ export interface ReconciliationPayload { export interface StellarReconnectPayload { /** Contract ID to reconnect to */ contractId: string; - + /** Last cursor position before disconnection */ lastCursor: string; } + +/** + * Refund job payload + * Used for executing on-chain refund operations via Soroban + */ +export interface RefundJobPayload { + /** Refund attempt ID */ + refundId: string; + + /** Idempotency key for preventing duplicate submissions */ + idempotencyKey: string; + + /** Entity type (payment, escrow, or link) */ + entityType: string; + + /** Entity ID to refund */ + entityId: string; +} diff --git a/app/backend/src/job-queue/types/job.types.ts b/app/backend/src/job-queue/types/job.types.ts index c356af460..ea39f2cf5 100644 --- a/app/backend/src/job-queue/types/job.types.ts +++ b/app/backend/src/job-queue/types/job.types.ts @@ -14,6 +14,7 @@ export enum JobType { EXPORT_GENERATION = 'export_generation', RECONCILIATION = 'reconciliation', STELLAR_RECONNECT = 'stellar_reconnect', + REFUND = 'refund', } /** diff --git a/app/backend/src/refunds/refunds.controller.ts b/app/backend/src/refunds/refunds.controller.ts index 81457dcfe..e1d7af988 100644 --- a/app/backend/src/refunds/refunds.controller.ts +++ b/app/backend/src/refunds/refunds.controller.ts @@ -63,8 +63,8 @@ export class RefundsController { @HttpCode(HttpStatus.OK) @UseGuards(NetworkSafetyGuard) @RequiresFlag('mainnet.refunds') - @ApiOperation({ summary: 'Approve a pending refund' }) - @ApiResponse({ status: 200, description: 'Refund approved' }) + @ApiOperation({ summary: 'Approve a pending refund and submit to on-chain' }) + @ApiResponse({ status: 200, description: 'Refund submitted for processing' }) @ApiResponse({ status: 409, description: 'Refund is not in pending state' }) @ApiResponse({ status: 503, description: 'Blocked by mainnet safety gate' }) async approve( @@ -111,4 +111,12 @@ export class RefundsController { const safeLimit = clampLimit(limit); return this.refundsService.listRefunds(parsedCursor, safeLimit); } + + @Get(':id') + @ApiOperation({ summary: 'Get refund details by ID' }) + @ApiResponse({ status: 200, description: 'Refund attempt details including transaction hash and retryability' }) + @ApiResponse({ status: 404, description: 'Refund not found' }) + async getById(@Param('id') id: string) { + return this.refundsService.getRefundByIdentity(id); + } } diff --git a/app/backend/src/refunds/refunds.module.ts b/app/backend/src/refunds/refunds.module.ts index ebbfdaaba..b693bf495 100644 --- a/app/backend/src/refunds/refunds.module.ts +++ b/app/backend/src/refunds/refunds.module.ts @@ -5,9 +5,11 @@ import { SupabaseModule } from "../supabase/supabase.module"; import { ApiKeysModule } from "../api-keys/api-keys.module"; import { FeatureFlagsModule } from "../feature-flags/feature-flags.module"; import { AuditModule } from "../audit/audit.module"; +import { AppConfigModule } from "../config"; +import { JobQueueModule } from "../job-queue/job-queue.module"; @Module({ - imports: [SupabaseModule, ApiKeysModule, FeatureFlagsModule, AuditModule], + imports: [SupabaseModule, ApiKeysModule, FeatureFlagsModule, AuditModule, AppConfigModule, JobQueueModule], providers: [RefundsService], controllers: [RefundsController], exports: [RefundsService], diff --git a/app/backend/src/refunds/refunds.service.ts b/app/backend/src/refunds/refunds.service.ts index 2fc079629..74ba60139 100644 --- a/app/backend/src/refunds/refunds.service.ts +++ b/app/backend/src/refunds/refunds.service.ts @@ -17,12 +17,19 @@ import { isLinkRefundable, } from './refunds.eligibility'; import { applyCursorFilter, paginateResult, CursorPayload } from '../common/pagination/cursor.util'; +import { JobQueueService } from '../job-queue/job-queue.service'; +import { JobType } from '../job-queue/types'; +import { AppConfigService } from '../config'; @Injectable() export class RefundsService { private readonly logger = new Logger(RefundsService.name); - constructor(private readonly supabaseService: SupabaseService) {} + constructor( + private readonly supabaseService: SupabaseService, + private readonly jobQueue: JobQueueService, + private readonly config: AppConfigService, + ) {} async initiateRefund( dto: InitiateRefundDto, @@ -88,16 +95,34 @@ export class RefundsService { const client = this.supabaseService.getClient(); const { data, error } = await client .from('refund_attempts') - .update({ status: 'approved', updated_at: new Date().toISOString() }) + .update({ status: 'submitted', updated_at: new Date().toISOString(), last_attempted_at: new Date().toISOString() }) .eq('id', id) .select() .single(); if (error) throw error; + const updatedRecord = data as RefundAttemptRecord; + await this.appendAudit(id, actorId, 'approved', null, null); - this.logger.log(`Refund approved id=${id}`); - return data as RefundAttemptRecord; + this.logger.log(`Refund approved and submitted id=${id}`); + + const payload = { + refundId: id, + idempotencyKey: record.idempotency_key, + entityType: record.entity_type, + entityId: record.entity_id, + }; + + try { + await this.jobQueue.enqueue(JobType.REFUND, payload); + this.logger.log(`Refund job enqueued for id=${id}`); + } catch (enqueueError) { + this.logger.error(`Failed to enqueue refund job: ${enqueueError.message}`); + throw enqueueError; + } + + return updatedRecord; } async rejectRefund( @@ -147,6 +172,10 @@ export class RefundsService { return paginateResult((data ?? []) as RefundAttemptRecord[], limit, 'created_at'); } + async getRefundByIdentity(id: string): Promise { + return this.fetchRefundById(id); + } + async getRefundByIdempotencyKey( key: string, ): Promise { @@ -165,7 +194,7 @@ export class RefundsService { // Private helpers // --------------------------------------------------------------------------- - private async getRefundById(id: string): Promise { + private async fetchRefundById(id: string): Promise { const client = this.supabaseService.getClient(); const { data, error } = await client .from('refund_attempts') @@ -180,6 +209,10 @@ export class RefundsService { return data as RefundAttemptRecord; } + private async getRefundById(id: string): Promise { + return this.fetchRefundById(id); + } + private async assertEligible( entityType: RefundableEntityType, entityId: string, diff --git a/app/backend/src/refunds/refunds.types.ts b/app/backend/src/refunds/refunds.types.ts index 3300914be..11f2d473b 100644 --- a/app/backend/src/refunds/refunds.types.ts +++ b/app/backend/src/refunds/refunds.types.ts @@ -1,6 +1,6 @@ export type RefundableEntityType = 'payment' | 'escrow' | 'link'; -export type RefundStatus = 'pending' | 'approved' | 'rejected' | 'failed'; +export type RefundStatus = 'pending' | 'approved' | 'submitted' | 'confirmed' | 'failed' | 'rejected'; export type RefundReasonCode = | 'DUPLICATE' @@ -19,6 +19,13 @@ export interface RefundAttemptRecord { actor_id: string; created_at: string; updated_at: string; + on_chain_tx_hash: string | null; + contract_id: string | null; + network: string | null; + failure_reason: string | null; + retry_count: number; + last_attempted_at: string | null; + is_retryable: boolean; } export interface RefundAuditRecord { diff --git a/app/backend/supabase/migrations/20260425000001_add_refund_onchain_tracking.sql b/app/backend/supabase/migrations/20260425000001_add_refund_onchain_tracking.sql new file mode 100644 index 000000000..1d44e57f0 --- /dev/null +++ b/app/backend/supabase/migrations/20260425000001_add_refund_onchain_tracking.sql @@ -0,0 +1,34 @@ +-- ============================================================================= +-- Refund on-chain tracking fields (BE-12 follow-up) +-- ============================================================================= +-- Adds fields to track on-chain transaction state and retry metadata +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- refund_attempts - add on-chain tracking columns +-- --------------------------------------------------------------------------- + +ALTER TABLE IF EXISTS refund_attempts + ADD COLUMN IF NOT EXISTS on_chain_tx_hash TEXT, + ADD COLUMN IF NOT EXISTS contract_id TEXT, + ADD COLUMN IF NOT EXISTS network TEXT, + ADD COLUMN IF NOT EXISTS failure_reason TEXT, + ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS last_attempted_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS is_retryable BOOLEAN NOT NULL DEFAULT true; + +-- Update status check constraint to include new states +ALTER TABLE IF EXISTS refund_attempts + DROP CONSTRAINT IF EXISTS refund_attempts_status_check, + ADD CONSTRAINT refund_attempts_status_check + CHECK (status IN ('pending', 'approved', 'submitted', 'confirmed', 'failed', 'rejected')); + +-- Create index for querying by on-chain transaction hash +CREATE INDEX IF NOT EXISTS idx_refund_attempts_tx_hash + ON refund_attempts (on_chain_tx_hash) + WHERE on_chain_tx_hash IS NOT NULL; + +-- Create index for querying by contract_id +CREATE INDEX IF NOT EXISTS idx_refund_attempts_contract_id + ON refund_attempts (contract_id) + WHERE contract_id IS NOT NULL; \ No newline at end of file From ace389f81e64aad06b05759b3de8b4c7ed53f656 Mon Sep 17 00:00:00 2001 From: Noble Date: Sun, 21 Jun 2026 10:21:20 +0000 Subject: [PATCH 2/3] fixing build errors --- app/backend/src/job-queue/handlers/refund-job.handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/backend/src/job-queue/handlers/refund-job.handler.ts b/app/backend/src/job-queue/handlers/refund-job.handler.ts index c401362ba..35d47bb40 100644 --- a/app/backend/src/job-queue/handlers/refund-job.handler.ts +++ b/app/backend/src/job-queue/handlers/refund-job.handler.ts @@ -8,7 +8,7 @@ import { ContractRegistryService } from '../../contracts/contract-registry.servi import { SorobanRpcService, SOROBAN_ERROR_CODES } from '../../transactions/soroban-rpc.service'; import { StellarSigningService } from '../../common/stellar-signing.service'; import { SorobanErrorCode } from '../../common/soroban-errors'; -import { RefundableEntityType } from '../../refunds/refunds.types'; +import { RefundableEntityType, RefundStatus } from '../../refunds/refunds.types'; @Injectable() export class RefundJobHandler implements JobHandler { From 53cbdd73943a000f139477836e5ddb1cfbb5b972 Mon Sep 17 00:00:00 2001 From: Noble Date: Sun, 21 Jun 2026 10:23:35 +0000 Subject: [PATCH 3/3] fix mobile TypeScript errors in deep-link-routing --- .../__tests__/deep-link-routing.test.ts | 30 +++++++++---------- app/mobile/utils/deep-link-routing.ts | 24 +++++++-------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/app/mobile/__tests__/deep-link-routing.test.ts b/app/mobile/__tests__/deep-link-routing.test.ts index fb5e8248d..9b72f0fbd 100644 --- a/app/mobile/__tests__/deep-link-routing.test.ts +++ b/app/mobile/__tests__/deep-link-routing.test.ts @@ -1,16 +1,16 @@ -import { is RustAcademyLink, parseTransactionDeepLink, resolveDeepLink } from '../utils/deep-link-routing'; +import { isRustAcademyLink, parseTransactionDeepLink, resolveDeepLink } from '../utils/deep-link-routing'; describe('deep link routing', () => { - it('recognizes RustAcademy domains and scheme URLs', () => { - expect(is RustAcademyLink(' RustAcademy://transaction/12345')).toBe(true); - expect(is RustAcademyLink('https:// RustAcademy.to/transaction/12345')).toBe(true); - expect(is RustAcademyLink('https://www. RustAcademy.to/jordan?amount=1.2')).toBe(true); - expect(is RustAcademyLink('https://example.com/transaction/12345')).toBe(false); + it('recognizes RustAcademy domains and scheme URLs', () => { + expect(isRustAcademyLink('RustAcademy://transaction/12345')).toBe(true); + expect(isRustAcademyLink('https://RustAcademy.to/transaction/12345')).toBe(true); + expect(isRustAcademyLink('https://www.RustAcademy.to/jordan?amount=1.2')).toBe(true); + expect(isRustAcademyLink('https://example.com/transaction/12345')).toBe(false); }); it('parses transaction deep links with query params', () => { const result = parseTransactionDeepLink( - 'https://www. RustAcademy.to/transaction/999?memo=hello&txHash=0xabc', + 'https://www.RustAcademy.to/transaction/999?memo=hello&txHash=0xabc', ); expect(result).toEqual({ id: '999', @@ -19,7 +19,7 @@ describe('deep link routing', () => { }); it('resolves payment confirmation links to the payment confirmation route', () => { - const result = resolveDeepLink('https:// RustAcademy.to/jordan?amount=12.5&asset=XLM'); + const result = resolveDeepLink('https://RustAcademy.to/jordan?amount=12.5&asset=XLM'); expect(result).toEqual({ route: { pathname: '/payment-confirmation', @@ -29,7 +29,7 @@ describe('deep link routing', () => { }); it('resolves transaction links to the transaction route', () => { - const result = resolveDeepLink(' RustAcademy://transaction/abc-123?status=Success&asset=XLM'); + const result = resolveDeepLink('RustAcademy://transaction/abc-123?status=Success&asset=XLM'); expect(result).toEqual({ route: { pathname: '/transaction/[id]', @@ -38,14 +38,14 @@ describe('deep link routing', () => { }); }); - it('returns an error for invalid RustAcademy links', () => { - const result = resolveDeepLink('https:// RustAcademy.to/transaction/'); - expect(result).toEqual({ error: 'Unsupported or expired RustAcademy link.' }); + it('returns an error for invalid RustAcademy links', () => { + const result = resolveDeepLink('https://RustAcademy.to/transaction/'); + expect(result).toEqual({ error: 'Unsupported or expired RustAcademy link.' }); }); - it('returns a generic error for malformed RustAcademy://transaction links', () => { - const result = resolveDeepLink(' RustAcademy://transaction/'); - expect(result).toEqual({ error: 'Unsupported or expired RustAcademy link.' }); + it('returns a generic error for malformed RustAcademy://transaction links', () => { + const result = resolveDeepLink('RustAcademy://transaction/'); + expect(result).toEqual({ error: 'Unsupported or expired RustAcademy link.' }); }); it('ignores unrelated URLs', () => { diff --git a/app/mobile/utils/deep-link-routing.ts b/app/mobile/utils/deep-link-routing.ts index 283f1c034..e04775df1 100644 --- a/app/mobile/utils/deep-link-routing.ts +++ b/app/mobile/utils/deep-link-routing.ts @@ -1,7 +1,7 @@ import { parsePaymentLink } from './parse-payment-link'; -const RustAcademy_HOSTS = [' RustAcademy.to', 'www. RustAcademy.to']; -const RustAcademy_SCHEME = ' RustAcademy'; +const RustAcademy_HOSTS = [" RustAcademy.to", "www. RustAcademy.to"]; +const RustAcademy_SCHEME = " RustAcademy"; export interface DeepLinkRoute { pathname: string; @@ -19,7 +19,7 @@ export function parseTransactionDeepLink( try { const url = new URL(raw); - if (url.protocol === `${ RustAcademy_SCHEME}:`) { + if (url.protocol === `${RustAcademy_SCHEME}:`) { const segments = url.pathname .replace(/^\/+/, '') .split('/') @@ -35,7 +35,7 @@ export function parseTransactionDeepLink( if ( (url.protocol === 'https:' || url.protocol === 'http:') && - RustAcademy_HOSTS.includes(url.hostname) + RustAcademy_HOSTS.includes(url.hostname) ) { const segments = url.pathname .replace(/^\/+/, '') @@ -55,13 +55,13 @@ export function parseTransactionDeepLink( return null; } -export function is RustAcademyLink(raw: string): boolean { +export function isRustAcademyLink(raw: string): boolean { try { const url = new URL(raw); return ( - url.protocol === `${ RustAcademy_SCHEME}:` || + url.protocol === `${RustAcademy_SCHEME}:` || ((url.protocol === 'https:' || url.protocol === 'http:') && - RustAcademy_HOSTS.includes(url.hostname)) + RustAcademy_HOSTS.includes(url.hostname)) ); } catch { return false; @@ -72,12 +72,12 @@ function looksLikePaymentLink(raw: string): boolean { try { const url = new URL(raw); - if (url.protocol === `${ RustAcademy_SCHEME}:`) { + if (url.protocol === `${RustAcademy_SCHEME}:`) { const segments = url.pathname.replace(/^\/+/, '').split('/').filter(Boolean); return segments.length === 0 || segments[0] !== 'transaction'; } - if ((url.protocol === 'https:' || url.protocol === 'http:') && RustAcademy_HOSTS.includes(url.hostname)) { + if ((url.protocol === 'https:' || url.protocol === 'http:') && RustAcademy_HOSTS.includes(url.hostname)) { const segments = url.pathname.replace(/^\/+/, '').split('/').filter(Boolean); return segments.length === 0 || segments[0] !== 'transaction'; } @@ -123,11 +123,11 @@ export function resolveDeepLink(raw: string): DeepLinkResolution { }; } - if (is RustAcademyLink(trimmed)) { + if (isRustAcademyLink(trimmed)) { return { error: looksLikePaymentLink(trimmed) - ? paymentResult.error ?? 'Unsupported or expired RustAcademy link.' - : 'Unsupported or expired RustAcademy link.', + ? paymentResult.error ?? 'Unsupported or expired RustAcademy link.' + : 'Unsupported or expired RustAcademy link.', }; }