From 3bcc639dc166dbe1b6106fd896599daab0b313c0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:41:25 +0200 Subject: [PATCH 1/9] fix(kyc): auto-complete NationalityData from the account's known nationality (#4188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(kyc): auto-complete NationalityData from the account's known nationality The NationalityData KYC step is a standalone entity that is only satisfied by an explicit submission through the step machinery. A RealUnit registration writes userData.nationality but never completes that step, and initiateStep — unlike CONTACT_DATA (from mail) and PERSONAL_DATA (from user fields) — had no auto-completion branch for it. The nationality captured on the registration form was therefore asked again on the dedicated nationality step (double capture) for every fresh RealUnit account. Auto-complete NATIONALITY_DATA in initiateStep when the account's nationality is already known and clean (allowed, non-residence-permit, no step errors), mirroring the sibling CONTACT_DATA/PERSONAL_DATA auto-completes. Residence-permit or disallowed nationalities and merged/blocked accounts are deliberately left IN_PROGRESS so they still go through the normal nationality step, which routes them to internal/manual review and pulls in the residence-permit follow-up step — never a blind complete. The step result stores the same slim { id, symbol } shape as the manual path. * test(kyc): initialize global Config for the NationalityData auto-complete tests The NATIONALITY_DATA branch reads Config.kyc.residencePermitCountries, and the global Config is a module-level let that is undefined until a ConfigService is constructed. Initialize it in beforeAll, matching the existing pattern. * test(kyc): use absolute mock import, drop vacuous review-spy assertion Import createCustomCountry via an absolute path (CONTRIBUTING; matches the convention in the sibling specs and the Country entity import). Remove the reviewNationalityData spy and its not-called assertion: the auto-complete branch never routes through that path, so the assertion was vacuous — the COMPLETED status and single save already cover the intended behaviour. --- .../services/__tests__/kyc.service.spec.ts | 103 ++++++++++++++++++ .../generic/kyc/services/kyc.service.ts | 26 +++++ 2 files changed, 129 insertions(+) diff --git a/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts index 7d4911deb5..29268d6fea 100644 --- a/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts +++ b/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts @@ -1,8 +1,11 @@ import { createMock } from '@golevelup/ts-jest'; import { ForbiddenException } from '@nestjs/common'; +import { Configuration, ConfigService } from 'src/config/config'; import { BlobContent } from 'src/integration/infrastructure/azure-storage.service'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { createCustomCountry } from 'src/shared/models/country/__mocks__/country.entity.mock'; +import { Country } from 'src/shared/models/country/country.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock'; import { UserData } from '../../../user/models/user-data/user-data.entity'; @@ -15,6 +18,8 @@ import { KycFile } from '../../entities/kyc-file.entity'; import { KycStep } from '../../entities/kyc-step.entity'; import { ContentType } from '../../enums/content-type.enum'; import { KycStepName } from '../../enums/kyc-step-name.enum'; +import { ReviewStatus } from '../../enums/review-status.enum'; +import { KycStepRepository } from '../../repositories/kyc-step.repository'; import { KycDocumentService } from '../integration/kyc-document.service'; import { SumsubService } from '../integration/sum-sub.service'; import { KycFileService } from '../kyc-file.service'; @@ -382,3 +387,101 @@ describe('KycService downloadIdentDocuments', () => { ); }); }); + +// initiateStep auto-satisfies the NationalityData step from the account's already-known nationality +// (captured e.g. during RealUnit registration) so the user is not asked for it a second time - but +// only for the clean case (allowed, non-residence-permit nationality, no step errors), which is +// completed in-memory. A residence-permit or disallowed nationality, a merged/blocked account, a +// missing nationality, or a repeat attempt (preventDirectEvaluation) is deliberately left +// IN_PROGRESS, so the user goes through the normal nationality step that routes it to the correct +// internal/manual review and pulls in the residence-permit follow-up step. Never a blind complete. +describe('KycService initiateStep NATIONALITY_DATA auto-complete', () => { + let service: KycService; + let kycStepRepo: jest.Mocked; + + const userWithNationality = (nationality?: Country, overrides: Partial = {}): UserData => + createCustomUserData({ kycHash: 'hash', kycSteps: [], nationality, ...overrides }); + + const initiateNationalityStep = (user: UserData, preventDirectEvaluation = false): Promise => + (service as any).initiateStep(user, KycStepName.NATIONALITY_DATA, undefined, preventDirectEvaluation); + + // the NATIONALITY_DATA branch reads Config.kyc.residencePermitCountries; initialize the global + // Config (a module-level `let` that is undefined until a ConfigService is constructed) + beforeAll(() => { + new ConfigService(new Configuration()); + }); + + beforeEach(() => { + kycStepRepo = createMock(); + // save returns the (mutated) step, mirroring the real repo, so initiateStep's return is that step + (kycStepRepo.save as jest.Mock).mockImplementation(async (step) => step); + + // initiateStep's NATIONALITY_DATA path only touches the repo (via the final save) and the pure + // getNationalityErrors helper; avoid wiring all constructor deps + service = Object.create(KycService.prototype); + (service as any).kycStepRepo = kycStepRepo; + }); + + it('completes the step in-memory from an allowed nationality and stores it as the result', async () => { + const nationality = createCustomCountry({ symbol: 'DE', nationalityEnable: true }); + const user = userWithNationality(nationality); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.COMPLETED); + expect(step.getResult()).toMatchObject({ nationality: { id: nationality.id, symbol: 'DE' } }); + // only the final persist runs + expect(kycStepRepo.save).toHaveBeenCalledTimes(1); + }); + + it('leaves a residence-permit nationality IN_PROGRESS for the normal step', async () => { + const nationality = createCustomCountry({ symbol: 'RU', nationalityEnable: true }); + const user = userWithNationality(nationality); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); + + it('leaves a disallowed nationality IN_PROGRESS for the normal step', async () => { + const nationality = createCustomCountry({ symbol: 'DE', nationalityEnable: false }); + const user = userWithNationality(nationality); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); + + it('leaves the step IN_PROGRESS when the account has no known nationality', async () => { + const user = userWithNationality(undefined); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); + + it('leaves the step IN_PROGRESS on a repeat attempt (preventDirectEvaluation)', async () => { + const nationality = createCustomCountry({ symbol: 'DE', nationalityEnable: true }); + const user = userWithNationality(nationality); + + const step = await initiateNationalityStep(user, true); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); + + // fail-closed: a merged (or blocked) account yields step errors from getNationalityErrors, so even + // an allowed nationality must not be auto-completed + it('leaves an allowed nationality IN_PROGRESS when the account is merged (fail-closed)', async () => { + const nationality = createCustomCountry({ symbol: 'DE', nationalityEnable: true }); + const user = userWithNationality(nationality, { status: UserDataStatus.MERGED }); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); +}); diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index a0741d6c97..7d9c68d81a 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -1346,6 +1346,31 @@ export class KycService { break; } + case KycStepName.NATIONALITY_DATA: + // Auto-satisfy the nationality step from the account's already-known + // nationality (e.g. captured during RealUnit registration), mirroring the + // CONTACT_DATA (mail) and PERSONAL_DATA (user fields) auto-completion + // above. Only the clean case is auto-completed (allowed, non-residence- + // permit nationality, no step errors); a residence-permit or disallowed + // nationality — or a merged/blocked account — is deliberately left + // IN_PROGRESS so the user goes through the normal nationality step, which + // routes it to the correct internal/manual review and pulls in the + // residence-permit follow-up step. Never a blind complete; without a known + // nationality or on a repeat attempt (preventDirectEvaluation) the step + // also stays IN_PROGRESS. + if ( + user.nationality && + !preventDirectEvaluation && + !Config.kyc.residencePermitCountries.includes(user.nationality.symbol) && + this.getNationalityErrors(kycStep, user.nationality).length === 0 + ) { + // Persist the same slim { id, symbol } shape the manual nationality + // step stores (updateNationalityStep), not the whole Country entity — + // avoids freezing mutable country config into an immutable step result. + kycStep.complete({ nationality: { id: user.nationality.id, symbol: user.nationality.symbol } }); + } + break; + case KycStepName.IDENT: if (kycStep.isSumsub) { kycStep.transactionId = SumsubService.transactionId(user, kycStep); @@ -1797,6 +1822,7 @@ export class KycService { users: true, kycSteps: { userData: true }, wallet: true, + nationality: true, }); } From dc2cb9ba7a6be0c7e1ff57137339bc65d34f2150 Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Mon, 13 Jul 2026 11:51:51 +0200 Subject: [PATCH 2/9] feat(realunit): show current REALU balance in the compliance dashboard (#4189) * feat(realunit): show current REALU balance in the compliance dashboard The customer list and the detail dossier now carry the customer's current REALU holdings: sum of the ponder-indexer balances over all wallet addresses of the account (share count, asset decimals applied). The holder set is cached for 5 minutes; when the indexer or the asset lookup is unavailable the balance is reported as unknown instead of failing the list. New bulk user query getUsersByUserDataIds avoids per-customer address lookups. * docs(realunit): note the deliberate check-evidence exception in the reduced-scope header --- .../generic/user/models/user/user.service.ts | 5 ++ .../realunit-compliance.service.spec.ts | 50 ++++++++++++++++ .../dto/realunit-compliance-dto.mapper.ts | 10 +++- .../realunit/dto/realunit-compliance.dto.ts | 9 ++- .../realunit/realunit-compliance.service.ts | 59 ++++++++++++++++++- 5 files changed, 128 insertions(+), 5 deletions(-) diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 2b4e6a24ef..9200a9a2c7 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -88,6 +88,11 @@ export class UserService { return this.userRepo.find({ where: { userData: { id: userDataId } }, relations }); } + async getUsersByUserDataIds(userDataIds: number[]): Promise { + if (!userDataIds.length) return []; + return this.userRepo.find({ where: { userData: { id: In(userDataIds) } }, relations: { userData: true } }); + } + async getUserByAddress(address: string, relations: FindOptionsRelations = {}): Promise { return this.userRepo.findOne({ where: { address }, relations }); } diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts index 4f385b3853..c8b35e928a 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts @@ -24,14 +24,18 @@ import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { SupportIssueService } from 'src/subdomains/supporting/support-issue/services/support-issue.service'; +import { HoldersDto } from 'src/subdomains/supporting/realunit/dto/realunit.dto'; import { REALUNIT_VISIBLE_TX_ASSETS, RealUnitComplianceService, } from 'src/subdomains/supporting/realunit/realunit-compliance.service'; +import { RealUnitService } from 'src/subdomains/supporting/realunit/realunit.service'; import { RealUnitScopeService } from 'src/subdomains/supporting/realunit/realunit-scope.service'; describe('RealUnitComplianceService', () => { @@ -39,6 +43,8 @@ describe('RealUnitComplianceService', () => { let scopeService: DeepMocked; let userDataService: DeepMocked; + let userService: DeepMocked; + let realUnitService: DeepMocked; let transactionService: DeepMocked; let bankDataService: DeepMocked; let buyService: DeepMocked; @@ -65,6 +71,13 @@ describe('RealUnitComplianceService', () => { return Object.assign(new KycStep(), values); } + // Balance resolution defaults: no wallet addresses, empty holder set, share token with 0 decimals. + function mockEmptyBalances(): void { + userService.getUsersByUserDataIds.mockResolvedValue([]); + realUnitService.getHolders.mockResolvedValue({ holders: [], pageInfo: { hasNextPage: false } } as HoldersDto); + realUnitService.getRealuAsset.mockResolvedValue({ decimals: 0 } as Asset); + } + // Baseline mocks for a dossier of an existing member with no data; tests override what they exercise. function mockDossierDefaults(): void { scopeService.assertCustomer.mockResolvedValue(undefined); @@ -83,6 +96,9 @@ describe('RealUnitComplianceService', () => { beforeEach(() => { scopeService = createMock(); userDataService = createMock(); + userService = createMock(); + realUnitService = createMock(); + mockEmptyBalances(); transactionService = createMock(); bankDataService = createMock(); buyService = createMock(); @@ -98,6 +114,8 @@ describe('RealUnitComplianceService', () => { service = new RealUnitComplianceService( scopeService, userDataService, + userService, + realUnitService, transactionService, bankDataService, buyService, @@ -314,6 +332,38 @@ describe('RealUnitComplianceService', () => { expect(result[0]).not.toHaveProperty('bankTx'); }); + it('sums the indexer balances over all wallet addresses of a member', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xAbC', userData: { id: 1 } as UserData }), + Object.assign(new User(), { address: '0xDeF', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [ + { address: '0xabc', balance: '100' }, + { address: '0xdef', balance: '50' }, + { address: '0x999', balance: '7' }, // foreign holder, not one of the member's addresses + ], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBe(150); + }); + + it('reports the balance as unknown (undefined) when the indexer is unavailable', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + realUnitService.getHolders.mockRejectedValue(new Error('indexer down')); + + const result = await service.searchCustomers(); + + expect(result).toHaveLength(1); + expect(result[0].balance).toBeUndefined(); + }); + it('returns an empty list (fail-closed) without a key when there are no RealUnit customers', async () => { scopeService.getCustomerIds.mockResolvedValue([]); diff --git a/src/subdomains/supporting/realunit/dto/realunit-compliance-dto.mapper.ts b/src/subdomains/supporting/realunit/dto/realunit-compliance-dto.mapper.ts index 4d6aab0a33..d450773abe 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-compliance-dto.mapper.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-compliance-dto.mapper.ts @@ -49,9 +49,10 @@ interface RealUnitDossierSlices { export class RealUnitComplianceDtoMapper { // --- CUSTOMER --- // - static toCustomerListDto(userData: UserData): RealUnitCustomerListDto { + static toCustomerListDto(userData: UserData, balance?: number): RealUnitCustomerListDto { return { id: userData.id, + balance, kycStatus: userData.kycStatus, kycLevel: userData.kycLevel, accountType: userData.accountType, @@ -60,9 +61,14 @@ export class RealUnitComplianceDtoMapper { }; } - static toCustomerDetailDto(userData: UserData, slices: RealUnitDossierSlices): RealUnitCustomerDetailDto { + static toCustomerDetailDto( + userData: UserData, + balance: number | undefined, + slices: RealUnitDossierSlices, + ): RealUnitCustomerDetailDto { return { id: userData.id, + balance, created: userData.created, accountType: userData.accountType, mail: userData.mail, diff --git a/src/subdomains/supporting/realunit/dto/realunit-compliance.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-compliance.dto.ts index 3d0b319f42..d75cfef0ee 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-compliance.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-compliance.dto.ts @@ -17,7 +17,8 @@ import { KycLevel, KycStatus, KycType } from 'src/subdomains/generic/user/models // constructed field-by-field from scratch (never the full DFX dossier minus keys), so no DFX-internal or AML // work-product field can ever leak by omission or drift. The nested collection DTOs below are RealUnit-specific and // STRUCTURALLY omit every DFX AML work product / compliance note (see the per-DTO notes) per the product owner's -// reduced-compliance-scope decision (2026-07-03). +// reduced-compliance-scope decision (2026-07-03) — with one deliberate exception: the two mandatory check +// evidences (ident + Dilisense name check) are exposed per the product owner's decision of 2026-07-09. export class RealUnitCustomerSearchQuery { @IsOptional() @@ -26,6 +27,9 @@ export class RealUnitCustomerSearchQuery { } export class RealUnitCustomerListDto { + // current REALU holdings (share count, summed over all wallet addresses); undefined = could not be resolved + balance?: number; + id: number; kycStatus: KycStatus; kycLevel?: KycLevel; @@ -159,6 +163,9 @@ export class RealUnitCustomerDetailDto { highRisk?: boolean; pep?: boolean; + // current REALU holdings (share count, summed over all wallet addresses); undefined = could not be resolved + balance?: number; + // --- Mandatory checks, resolved by the api (absent member = check missing) --- // checks: RealUnitChecksDto; diff --git a/src/subdomains/supporting/realunit/realunit-compliance.service.ts b/src/subdomains/supporting/realunit/realunit-compliance.service.ts index 5bf9192c50..af19682349 100644 --- a/src/subdomains/supporting/realunit/realunit-compliance.service.ts +++ b/src/subdomains/supporting/realunit/realunit-compliance.service.ts @@ -1,5 +1,7 @@ import { Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import JSZip from 'jszip'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; +import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; import { Config } from 'src/config/config'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -18,11 +20,13 @@ import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { SupportIssueService } from 'src/subdomains/supporting/support-issue/services/support-issue.service'; import { RealUnitComplianceDtoMapper } from './dto/realunit-compliance-dto.mapper'; import { RealUnitCustomerDetailDto, RealUnitCustomerListDto, RealUnitKycFileDto } from './dto/realunit-compliance.dto'; +import { RealUnitService } from './realunit.service'; import { RealUnitScopeService } from './realunit-scope.service'; // Postgres integer upper bound: larger numeric keys cannot be an id and would fail the DB query @@ -58,9 +62,14 @@ export const REALUNIT_VISIBLE_TX_ASSETS: string[] = ['REALU', 'ZCHF']; export class RealUnitComplianceService { private readonly logger = new DfxLogger(RealUnitComplianceService); + // address (lowercase) -> raw REALU balance from the ponder indexer; refreshed lazily every 5 minutes + private readonly holderBalanceCache = new AsyncCache>(CacheItemResetPeriod.EVERY_5_MINUTES); + constructor( private readonly scopeService: RealUnitScopeService, private readonly userDataService: UserDataService, + private readonly userService: UserService, + private readonly realUnitService: RealUnitService, private readonly transactionService: TransactionService, private readonly bankDataService: BankDataService, private readonly buyService: BuyService, @@ -90,7 +99,9 @@ export class RealUnitComplianceService { 'id', ).sort((a, b) => a.id - b.id); - return members.map(RealUnitComplianceDtoMapper.toCustomerListDto); + const balances = await this.getMemberBalances(members.map((m) => m.id)); + + return members.map((m) => RealUnitComplianceDtoMapper.toCustomerListDto(m, balances.get(m.id))); } // --- REDUCED DOSSIER --- // @@ -124,7 +135,9 @@ export class RealUnitComplianceService { this.supportIssueService.getIssueEntities(id), ]); - return RealUnitComplianceDtoMapper.toCustomerDetailDto(userData, { + const balance = (await this.getMemberBalances([id])).get(id); + + return RealUnitComplianceDtoMapper.toCustomerDetailDto(userData, balance, { kycFiles: this.filterDownloadableFiles(kycFiles), kycSteps, transactions, @@ -251,6 +264,48 @@ export class RealUnitComplianceService { return files.filter((f) => REALUNIT_DOWNLOADABLE_FILE_TYPES.includes(f.type)); } + // Current REALU holdings per customer: sum of the indexer balances over all wallet addresses of the account. + // Returns share counts (asset decimals applied). Fail-open to an EMPTY map when the indexer or asset lookup is + // unavailable — the dashboard then shows the balance as unknown instead of failing the whole customer list. + private async getMemberBalances(userDataIds: number[]): Promise> { + try { + const [users, holderBalances, realuAsset] = await Promise.all([ + this.userService.getUsersByUserDataIds(userDataIds), + this.getHolderBalances(), + this.realUnitService.getRealuAsset(), + ]); + + const balances = new Map(userDataIds.map((id) => [id, 0])); + for (const user of users) { + const raw = user.address && holderBalances.get(user.address.toLowerCase()); + if (!raw) continue; + + const userDataId = user.userData.id; + balances.set(userDataId, (balances.get(userDataId) ?? 0) + EvmUtil.fromWeiAmount(raw, realuAsset.decimals)); + } + + return balances; + } catch (e) { + this.logger.warn('Could not resolve REALU balances:', e); + return new Map(); + } + } + + private async getHolderBalances(): Promise> { + return this.holderBalanceCache.get('holders', async () => { + const map = new Map(); + + let after: string | undefined; + do { + const page = await this.realUnitService.getHolders(1000, undefined, after); + for (const holder of page.holders) map.set(holder.address.toLowerCase(), holder.balance); + after = page.pageInfo?.hasNextPage ? page.pageInfo.endCursor : undefined; + } while (after); + + return map; + }); + } + // ZIP entry paths must not carry traversal payloads from customer-influenced file names. private sanitizePathComponent(name: string): string { return name.replace(/[\\/]/g, '_').replace(/\.\.+/g, '_'); From 56589a7b23ccfdd6bc2eb2c733d85529baf4ae04 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:05:35 +0200 Subject: [PATCH 3/9] fix(realunit): fail closed on empty or truncated indexer sweeps for compliance balances (#4191) * fix(realunit): fail closed on empty or truncated indexer sweeps for compliance balances Follow-up to #4189. The REALU balance shown per compliance customer is resolved from the Ponder indexer and cached for 5 minutes; three fail-open weaknesses let a degraded indexer response render a wrong authoritative value instead of "unknown": - A successful-but-empty sweep (HTTP 200, no holders during resync/cold start) was cached and shown as a definitive 0 for real shareholders, contradicting the documented undefined=unresolved contract. Empty sweeps now throw and stay unresolved (not cached). - Pagination stopped silently on a missing/repeated cursor and had no page cap, risking a silent undercount. It now fails closed on a stalled cursor and caps at 100 pages, mirroring the deuro/juice clients but throwing instead of returning a partial set (balance correctness over completeness). - A single unparsable holder balance made fromWeiAmount throw and blanked the whole batch; malformed records are now skipped per address. Adds tests for the empty-sweep, malformed-holder, missing-cursor and dossier-detail paths. * fix(realunit): fail closed per member on an unparsable holder balance Skipping a malformed balance left the affected member on a false authoritative 0 (or a partial-sum undercount), contradicting the undefined=unresolved contract the rest of the change enforces. Any unparsable address now marks that member's total as unresolved (undefined) while the other members stay correct. Adds tests for member-level isolation, the no-partial-sum case, and the 100-page cap guard. * test(realunit): isolate the page-cap and repeated-cursor pagination guards The page-cap test used empty pages, so its undefined result was equally produced by the empty-sweep guard; it now returns non-empty foreign holders so only the page-cap guard can yield undefined. Adds a test for the repeated-cursor stall, asserting it is caught on the second page rather than only after the 100-page cap. * fix(realunit): treat an empty-string holder balance as unresolved, not a false 0 The truthiness guard (`if (!raw) continue`) swallowed an empty-string balance the same way as an absent address, masking an unusable indexer value as an authoritative 0. Now only a truly absent address short-circuits; a present-but-empty balance flows through toShareCount and fails closed (member -> unresolved). Also adds a positive test for cursor-advance + cross-page holder aggregation. * style(realunit): satisfy prettier in the balance spec format:check flagged the object-literal cast in the page-cap test; prettier 3 formats it as `({...}) as HoldersDto`. --- .../realunit-compliance.service.spec.ts | 177 +++++++++++++++++- .../realunit/realunit-compliance.service.ts | 64 ++++++- 2 files changed, 233 insertions(+), 8 deletions(-) diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts index c8b35e928a..bfe9f84440 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts @@ -71,10 +71,15 @@ describe('RealUnitComplianceService', () => { return Object.assign(new KycStep(), values); } - // Balance resolution defaults: no wallet addresses, empty holder set, share token with 0 decimals. + // Balance resolution defaults: the member has no wallet addresses, but the indexer DOES resolve (a single + // foreign holder keeps the set non-empty) — so members resolve to 0 (holds nothing), not unknown. Share + // token with 0 decimals. function mockEmptyBalances(): void { userService.getUsersByUserDataIds.mockResolvedValue([]); - realUnitService.getHolders.mockResolvedValue({ holders: [], pageInfo: { hasNextPage: false } } as HoldersDto); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xf000000000000000000000000000000000000000', balance: '0' }], + pageInfo: { hasNextPage: false }, + } as HoldersDto); realUnitService.getRealuAsset.mockResolvedValue({ decimals: 0 } as Asset); } @@ -291,6 +296,21 @@ describe('RealUnitComplianceService', () => { expect(dossier.checks.identCheck).toBeUndefined(); expect(dossier.checks.nameCheck).toBeUndefined(); }); + + it('resolves the member REALU balance in the dossier detail', async () => { + mockDossierDefaults(); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xAaA', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xaaa', balance: '250' }], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const dossier = await service.getReducedDossier(1); + + expect(dossier.balance).toBe(250); + }); }); describe('searchCustomers', () => { @@ -364,6 +384,159 @@ describe('RealUnitComplianceService', () => { expect(result[0].balance).toBeUndefined(); }); + it('reports the balance as unknown (undefined) when the indexer resolves but returns no holders', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ holders: [], pageInfo: { hasNextPage: false } } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + + it('marks the member with an unparsable holder balance as unknown and leaves the others correct', async () => { + scopeService.getCustomerIds.mockResolvedValue([1, 2]); + userDataService.getUserDataByIds.mockResolvedValue([ + Object.assign(new UserData(), { id: 1 }), + Object.assign(new UserData(), { id: 2 }), + ]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + Object.assign(new User(), { address: '0xdef', userData: { id: 2 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [ + { address: '0xabc', balance: 'not-a-number' }, // member 1: unresolvable -> undefined, not a false 0 + { address: '0xdef', balance: '50' }, // member 2: unaffected by the poisoned record + ], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result.find((u) => u.id === 1)?.balance).toBeUndefined(); + expect(result.find((u) => u.id === 2)?.balance).toBe(50); + }); + + it('reports the balance as unknown when any of a member address balances is unparsable (no partial sum)', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + Object.assign(new User(), { address: '0xdef', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [ + { address: '0xabc', balance: '50' }, // valid first... + { address: '0xdef', balance: 'not-a-number' }, // ...then malformed -> whole member unknown, not 50 + ], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + + it('reports the balance as unknown when the holder pagination exceeds the page cap', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + // Non-empty foreign holders on every page: the map is never empty, so the undefined result is produced by + // the page-cap guard alone (not by the empty-sweep guard) — removing `if (!complete) throw` would resolve + // the member to 0 and fail this test. + let cursor = 0; + realUnitService.getHolders.mockImplementation( + async () => + ({ + holders: [{ address: `0xf00${cursor}`, balance: '1' }], + pageInfo: { hasNextPage: true, endCursor: `cursor-${cursor++}` }, + }) as HoldersDto, + ); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + + it('detects a stalled indexer that repeats the same pagination cursor (on the second page)', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xfff', balance: '1' }], + pageInfo: { hasNextPage: true, endCursor: 'stuck' }, // same cursor every call + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + // The repeated-cursor guard must catch it on page 2, not only after the 100-page cap. + expect(realUnitService.getHolders).toHaveBeenCalledTimes(2); + }); + + it('reports the balance as unknown when a holder balance is an empty string', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xabc', balance: '' }], // present but unusable -> unresolved, not a false 0 + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + + it('aggregates a member holdings across multiple legitimately-cursored pages', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + Object.assign(new User(), { address: '0xdef', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders + .mockResolvedValueOnce({ + holders: [{ address: '0xabc', balance: '40' }], + pageInfo: { hasNextPage: true, endCursor: 'p1' }, + } as HoldersDto) + .mockResolvedValueOnce({ + holders: [{ address: '0xdef', balance: '60' }], // only reachable by advancing the cursor to page 2 + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBe(100); + expect(realUnitService.getHolders).toHaveBeenCalledTimes(2); + }); + + it('reports the balance as unknown (undefined) when the indexer signals another page without a cursor', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xabc', balance: '100' }], + pageInfo: { hasNextPage: true }, + } as unknown as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + it('returns an empty list (fail-closed) without a key when there are no RealUnit customers', async () => { scopeService.getCustomerIds.mockResolvedValue([]); diff --git a/src/subdomains/supporting/realunit/realunit-compliance.service.ts b/src/subdomains/supporting/realunit/realunit-compliance.service.ts index af19682349..9ef2b65315 100644 --- a/src/subdomains/supporting/realunit/realunit-compliance.service.ts +++ b/src/subdomains/supporting/realunit/realunit-compliance.service.ts @@ -276,14 +276,33 @@ export class RealUnitComplianceService { ]); const balances = new Map(userDataIds.map((id) => [id, 0])); + const unresolved = new Set(); for (const user of users) { - const raw = user.address && holderBalances.get(user.address.toLowerCase()); - if (!raw) continue; + if (!user.address) continue; + + // "Address absent from the holder set" is a genuine 0 (already pre-seeded). An address that IS present but + // carries an unusable balance (e.g. '' from a resyncing indexer) must flow through toShareCount and fail + // closed below — not be swallowed here as a false 0 by a truthiness check. + const raw = holderBalances.get(user.address.toLowerCase()); + if (raw === undefined) continue; const userDataId = user.userData.id; - balances.set(userDataId, (balances.get(userDataId) ?? 0) + EvmUtil.fromWeiAmount(raw, realuAsset.decimals)); + + // A single malformed indexer balance must not blank the whole batch — but it must not be masked as an + // authoritative 0 / undercount either. Any unparsable address makes a member's total unknowable, so fail + // closed for THAT member (-> undefined) while the rest stay correct. + const shares = this.toShareCount(raw, realuAsset.decimals); + if (shares === undefined) { + unresolved.add(userDataId); + continue; + } + + balances.set(userDataId, (balances.get(userDataId) ?? 0) + shares); } + // Order-safe: drop poisoned members only at the end, so a later valid address cannot re-add one. + for (const id of unresolved) balances.delete(id); + return balances; } catch (e) { this.logger.warn('Could not resolve REALU balances:', e); @@ -291,16 +310,49 @@ export class RealUnitComplianceService { } } + private toShareCount(rawWei: string, decimals: number): number | undefined { + try { + return EvmUtil.fromWeiAmount(rawWei, decimals); + } catch (e) { + this.logger.warn(`Ignoring unparsable REALU holder balance "${rawWei}":`, e); + return undefined; + } + } + private async getHolderBalances(): Promise> { return this.holderBalanceCache.get('holders', async () => { const map = new Map(); + const seenCursors = new Set(); + const maxPages = 100; let after: string | undefined; - do { + let complete = false; + for (let i = 0; i < maxPages; i++) { const page = await this.realUnitService.getHolders(1000, undefined, after); for (const holder of page.holders) map.set(holder.address.toLowerCase(), holder.balance); - after = page.pageInfo?.hasNextPage ? page.pageInfo.endCursor : undefined; - } while (after); + + if (!page.pageInfo?.hasNextPage) { + complete = true; + break; + } + + // A next page without a fresh cursor would silently truncate the sweep and cache the partial set as if + // complete (undercounting real holders). The sibling clients (deuro/juice) return the partial set here; + // balance correctness requires failing closed instead, so the dashboard shows "unknown", not an undercount. + const endCursor = page.pageInfo.endCursor; + if (!endCursor || seenCursors.has(endCursor)) throw new Error('RealUnit holder pagination stalled'); + seenCursors.add(endCursor); + after = endCursor; + } + + // Never cache a partial sweep as authoritative: exhausting the page cap means the full holder set could + // not be read, so treat it as unresolved rather than undercounting. + if (!complete) throw new Error(`RealUnit holder pagination exceeded ${maxPages} pages`); + + // An empty holder set means the indexer could not answer (a resyncing / cold-starting Ponder returns HTTP + // 200 with no accounts): treat it as unresolved rather than caching an authoritative zero — that would + // render real shareholders as a definitive 0 for the whole 5-minute cache window. + if (!map.size) throw new Error('RealUnit indexer returned no holders'); return map; }); From 7c65324d6533e94221161361c5a436adbde3f054 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:54:49 +0200 Subject: [PATCH 4/9] test(realunit): cover the registration forward self-heal and idempotent KYC lift (#4195) * test(realunit): cover the registration forward self-heal and idempotent KYC lift Two gaps remained after the forward-outside-transaction change: - self-heal on retry: a persist failure after a successful (idempotent-upsert) POST returns false and leaves no blocking row, so the client retry re-POSTs and completes. The load-bearing self-heal property was only asserted in prose. - the in-lock idempotent outcome (a concurrent completion of the same wallet) must still lift the caller's KYC level so its buy/sell gate opens; the existing idempotent test used a level-999 user and never exercised the lift. * test(realunit): correct the self-heal assertion rationale The registerUser POST runs unconditionally outside the persist transaction, so its call count does not prove the retry was not short-circuited. Attribute the self-heal to the second attempt persisting a COMPLETED row instead. --- .../__tests__/realunit.service.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index caca3640f0..aab04c664f 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1668,6 +1668,33 @@ describe('RealUnitService', () => { expect(logService.create).toHaveBeenCalledWith(expect.objectContaining({ severity: LogSeverity.ERROR })); }); + it('self-heals on retry: after a persist failure the client retry re-POSTs (harmless upsert) and completes', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + httpService.post.mockResolvedValue({} as any); + // first attempt: the POST succeeds but the COMPLETED persist fails -> the transaction rolls back, no row + aktionariatTxManager.save.mockRejectedValueOnce(new Error('db down')); + + const first = await (service as any).forwardRegistration(fakeUserData(10), dto); + + // nothing durable was written -> returns false, but the failed attempt leaves no blocking row + expect(first).toBe(false); + expect(httpService.post).toHaveBeenCalledTimes(1); + + // client retry: registerUser is an idempotent upsert, so re-POSTing is harmless; the persist now succeeds + const second = await (service as any).forwardRegistration(fakeUserData(10), dto); + + expect(second).toBe(true); + // the POST runs unconditionally (outside the persist txn), so its count alone does not prove the retry; + // the self-heal is shown by the second attempt actually persisting a COMPLETED row — the rolled-back + // first attempt left no active row to short-circuit it. + expect(httpService.post).toHaveBeenCalledTimes(2); + expect(aktionariatTxManager.save).toHaveBeenCalledTimes(2); + const persisted = (aktionariatRegistrationRepo.create as jest.Mock).mock.calls.at(-1)[0]; + expect(persisted.status).toBe(ReviewStatus.COMPLETED); // the retry persists the COMPLETED registration + }); + it('re-checks idempotency inside a per-wallet-user advisory lock and does not persist again', async () => { const wallet = softwareWallet.address; const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); @@ -1694,6 +1721,25 @@ describe('RealUnitService', () => { expect(aktionariatTxManager.save).not.toHaveBeenCalled(); }); + it('lifts the KYC level on the in-lock idempotent outcome (concurrent completion of the same wallet)', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + userService.getUserByAddress.mockResolvedValue({ id: 55 } as any); + httpService.post.mockResolvedValue({} as any); + // a concurrent caller already completed this wallet (same signature) -> our persist short-circuits idempotent + aktionariatTxManager.findOne.mockResolvedValue({ id: 9, status: ReviewStatus.COMPLETED, signature }); + + const ok = await (service as any).forwardRegistration(fakeUserData(10), dto); + + expect(ok).toBe(true); + expect(aktionariatTxManager.save).not.toHaveBeenCalled(); // no duplicate persist on the idempotent outcome + // the idempotent outcome must still (best-effort) lift THIS caller's KYC level, else its buy/sell gate stays shut + expect(userDataService.updateUserDataInternal).toHaveBeenCalledTimes(1); + const [, update] = (userDataService.updateUserDataInternal as jest.Mock).mock.calls[0]; + expect(update.kycLevel).toBe(20); + }); + it('rejects an in-lock COMPLETED short-circuit when the incoming signature differs', async () => { const wallet = softwareWallet.address; const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); From e4bd5316b2b3cd791968a28a61b74b1e0f837157 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:01:57 +0200 Subject: [PATCH 5/9] fix(payout): prevent double payouts via designate-before-broadcast + structural broadcast boundary (all chains) (#4181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(payout): designate before broadcast to prevent double payout on restart Payout strategies broadcast on-chain before persisting any state, so a restart between the broadcast and the save leaves the order re-selectable by the 30s cron and triggers a second broadcast (double payout). Persist PAYOUT_DESIGNATED before the broadcast (mirrors the Bitcoin path) in the EVM, Solana, Tron, Cardano, ICP, Arkade, Spark and Lightning strategies. The !payoutTxId guard leaves the TX_SPEEDUP/expired-retry path untouched so nonce-reuse stays intact; the catch stays fail-closed and never auto-rolls-back. Guard speedupTransaction on PAYOUT_PENDING. No migration needed (PAYOUT_DESIGNATED already exists). * fix(payout): guard speedup to replacement-capable chains, extract designate helper Address review: speedupTransaction now rejects chains without nonce-reuse (supportsSpeedup=false, EVM-only) and when TX_SPEEDUP is disabled, so a manual speedup never broadcasts a second, independent transaction (double payout). Extract the designate-before-broadcast block into a shared PayoutStrategy helper to remove the 8-fold duplication and the EVM-specific comment that was misleading for the non-EVM chains. * test(payout): use toHaveBeenCalled matchers per repo convention * test(payout): cover the reboot double-spend case across all strategies Exhaustive tests for the designate-before-broadcast fix: all 8 payout strategies x the variant matrix (success = designate persisted before the broadcast; broadcast-throws stays PAYOUT_DESIGNATED with no second broadcast and no rollback; payoutTxId-set skips re-designation; designate-save-throws never reaches the broadcast; Lightning also isHealthy=false). Plus the PayoutStrategy helper and supportsSpeedup, speedupTransaction (all guard branches incl. not-found), the EVM retry x guard interaction (OOG fresh nonce vs. expired nonce reuse), and the cron-level reboot guarantee (payoutOrders skips PAYOUT_DESIGNATED, processFailedOrders escalates it to PAYOUT_UNCERTAIN). base/payout.strategy.ts reaches 100% coverage. * test(payout): reach 100% coverage on all changed payout files Cover the case-adjacent code in the touched files so every changed file hits 100% statement/branch/function/line coverage: the full PayoutService cron (doPayout, checkOrderCompletion, fee estimation, prepare/completion phases, getRecentPayoutSentCorrelationIds, group/mail helpers, per-group error isolation across two groups) and estimateFee / checkPayoutCompletionData for all eight strategies (incl. the ICP input-asset and TOKEN/COIN specifics and Lightning's undefined assetType). 225 payout tests pass on Node 20. * fix(payout): self-heal transient pre-broadcast failures via broadcast boundary Chain clients now throw TxBroadcastError only around the actual on-chain send call; the payout service maps it to PayoutBroadcastException. The strategy catch discriminates: an ambiguous broadcast error (at or after send) leaves the order PAYOUT_DESIGNATED for fail-closed escalation, while a provably pre-broadcast error rolls the designation back to PREPARATION_CONFIRMED so the cron self-heals transient RPC failures — only on the first attempt and capped by maxPreBroadcastRetries. Restores the develop-era self-healing that the designate-before-broadcast fix had turned into a permanent strand for transient dispatch errors (RPC timeout, gas estimation, nonce fetch). Covers EVM, Cardano and Solana; adds an empty-tx-hash guard inside the EVM send boundary. * fix(payout): extend broadcast boundary to ICP and Lightning Apply the TxBroadcastError / PayoutBroadcastException boundary to the ICP and Lightning payout paths, so a transient pre-broadcast failure self-heals while an ambiguous at-or-after-send failure stays PAYOUT_DESIGNATED for fail-closed escalation. - ICP: hoist address/amount validation out of the try so only the ledger update call is wrapped; a post-submit network error is ambiguous and the call carries no created_at_time dedup, so it stays fail-closed. - Lightning: wrap the transport-level send in lightning-client; an in-band LND payment_error ("no route") remains a plain error and self-heals. Keysend (LN_NID) has no payment_hash for LND dedup, so its failures are wrapped fail-closed to prevent a double-pay on retry. - Solana: treat an empty tx hash after send as a broadcast error (fail-closed) instead of letting it fall through to a pre-broadcast-style rollback. * fix(payout): extend broadcast boundary to Bitcoin, Firo, Monero, Zano Replace the fragile substring heuristic (e.message.includes('timeout')) in the batch-based bitcoin path with the structural PayoutBroadcastException marker, so the pre/post-broadcast decision no longer depends on error wording: any at-or-after-send failure now keeps the batch PAYOUT_DESIGNATED (fail-closed) while a provable pre-broadcast failure rolls back for self-heal. - Bitcoin/Firo/Testnet4: wrap the actual node broadcast (Core `send` is atomic; Firo's sendrawtransaction / mintspark) in TxBroadcastError, mapped to PayoutBroadcastException in the payout services; fee-estimation stays outside. - Monero/Zano: wrap the atomic wallet-RPC transfer, including response mapping, so a malformed body cannot fall through as a pre-broadcast-style rollback. * fix(payout): extend broadcast boundary to Tron, Arkade, Spark Tron's gateway POST and the Arkade/Spark SDK sends are atomic (sign+broadcast in one call), so any failure from the send on is ambiguous and now surfaces as TxBroadcastError -> PayoutBroadcastException (fail-closed). Their strategies move from a blanket fail-closed catch to handleBroadcastError, so a provable pre-broadcast failure (wallet resolution, decimals, leaf sync) self-heals. - Arkade/Spark: extract the actual broadcast (wallet.sendBitcoin / wallet.transfer) out of the internal reconnect-and-retry wrapper, which would otherwise resend an already-broadcast tx on a connection/channel error -> double-spend. - Guard every send against an empty txId (200 OK without a tx id): treat it as an ambiguous broadcast error instead of returning an empty id that would later roll the order back for re-broadcast, mirroring the Solana empty-hash guard. * test(payout): reach 100% coverage across the payout execution domain Cover the normal (happy-path) execution of every payout strategy and service the broadcast-boundary fix touches or sits next to: the 40+ chain-derivation strategies (dispatch, getters, fee asset), all payout services (completion data, gas, nonce, expiry), the base strategies (batch send, designate/rollback, grouping), and the payout order entity and repository. Every in-scope file is now 100% in statements, branches, functions and lines. * fix(payout): fail-closed on empty Lightning keysend payment hash A keysend (LN_NID) carries no invoice payment_hash, so LND cannot deduplicate a re-broadcast and a self-healing retry would double-pay. sendTransfer returns an empty string for a blank payment hash without throwing, which would otherwise roll the order back to PREPARATION_CONFIRMED and re-broadcast. Guard the empty return (and keep wrapping every keysend error) as a PayoutBroadcastException so the order stays PAYOUT_DESIGNATED (fail-closed). Invoice payouts (LN_URL/LND_HUB) keep self-healing, since their payment_hash lets LND drop a duplicate. --- src/config/config.ts | 7 + .../arkade/__tests__/arkade-client.spec.ts | 95 ++ .../blockchain/arkade/arkade-client.ts | 36 +- .../node/__tests__/bitcoin-client.spec.ts | 43 + .../bitcoin/node/bitcoin-based-client.ts | 14 +- .../cardano/__tests__/cardano-client.spec.ts | 91 ++ .../blockchain/cardano/cardano-client.ts | 8 +- .../firo/__tests__/firo-client.spec.ts | 248 +++++ .../blockchain/firo/firo-client.ts | 27 +- .../icp/__tests__/icp-client.spec.ts | 202 ++++ src/integration/blockchain/icp/icp-client.ts | 29 +- .../monero/__tests__/monero-client.spec.ts | 134 +++ .../blockchain/monero/monero-client.ts | 26 +- .../__tests__/tx-broadcast.error.spec.ts | 20 + .../shared/errors/tx-broadcast.error.ts | 9 + .../shared/evm/__tests__/evm-client.spec.ts | 215 ++++ .../blockchain/shared/evm/evm-client.ts | 33 +- .../solana/__tests__/solana-client.spec.ts | 125 +++ .../blockchain/solana/solana-client.ts | 8 +- .../spark/__tests__/spark-client.spec.ts | 164 +++ .../blockchain/spark/spark-client.ts | 32 +- .../tron/__tests__/tron-client.spec.ts | 183 ++++ .../blockchain/tron/tron-client.ts | 39 +- .../zano/__test__/zano-client.spec.ts | 157 +++ .../blockchain/zano/zano-client.ts | 24 +- .../__tests__/lightning-client.spec.ts | 137 +++ src/integration/lightning/lightning-client.ts | 47 +- .../__tests__/payout-order.entity.spec.ts | 150 ++- .../payout-broadcast.exception.spec.ts | 20 + .../exceptions/payout-broadcast.exception.ts | 9 + .../__tests__/payout-order.repository.spec.ts | 9 + .../__tests__/payout-arkade.service.spec.ts | 122 +++ .../payout-bitcoin-testnet4.service.spec.ts | 192 ++++ .../__tests__/payout-bitcoin.service.spec.ts | 124 +++ .../__tests__/payout-cardano.service.spec.ts | 155 +++ .../payout-evm-chains.service.spec.ts | 168 +++ .../__tests__/payout-evm.service.spec.ts | 244 +++++ .../__tests__/payout-firo.service.spec.ts | 228 +++++ .../__tests__/payout-icp.service.spec.ts | 178 ++++ .../payout-lightning.service.spec.ts | 197 ++++ .../__tests__/payout-monero.service.spec.ts | 154 +++ .../__tests__/payout-solana.service.spec.ts | 158 +++ .../__tests__/payout-spark.service.spec.ts | 122 +++ .../__tests__/payout-tron.service.spec.ts | 155 +++ .../__tests__/payout-zano.service.spec.ts | 218 ++++ .../services/__tests__/payout.service.spec.ts | 873 ++++++++++++++++ .../payout/services/payout-arkade.service.ts | 10 +- .../payout-bitcoin-testnet4.service.ts | 10 +- .../payout/services/payout-bitcoin.service.ts | 9 +- .../payout/services/payout-cardano.service.ts | 18 +- .../payout/services/payout-evm.service.ts | 16 +- .../payout/services/payout-firo.service.ts | 18 +- .../payout/services/payout-icp.service.ts | 16 +- .../services/payout-lightning.service.ts | 26 +- .../payout/services/payout-monero.service.ts | 12 +- .../payout/services/payout-solana.service.ts | 18 +- .../payout/services/payout-spark.service.ts | 10 +- .../payout/services/payout-tron.service.ts | 18 +- .../payout/services/payout-zano.service.ts | 16 +- .../payout/services/payout.service.ts | 14 + .../payout-bitcoin-based.strategy.spec.ts | 374 ++++++- ...esignate-before-broadcast.strategy.spec.ts | 334 ++++++ .../payout/__tests__/payout-evm-retry.spec.ts | 346 +++++++ .../__tests__/payout-evm-strategies.spec.ts | 295 ++++++ .../payout-nonevm-strategies.spec.ts | 967 ++++++++++++++++++ ...ayout-strategy-fee-completion.base.spec.ts | 693 +++++++++++++ ...ayout-strategy-fee-completion.leaf.spec.ts | 434 ++++++++ .../__tests__/payout-zano.strategy.spec.ts | 163 +++ .../__tests__/payout.strategy.base.spec.ts | 271 +++++ .../strategies/payout/impl/arkade.strategy.ts | 4 + .../impl/base/bitcoin-based.strategy.ts | 8 +- .../payout/impl/base/cardano.strategy.ts | 4 + .../payout/impl/base/evm.strategy.ts | 9 + .../payout/impl/base/icp.strategy.ts | 4 + .../payout/impl/base/payout.strategy.ts | 35 + .../payout/impl/base/solana.strategy.ts | 4 + .../payout/impl/base/tron.strategy.ts | 4 + .../payout/impl/lightning.strategy.ts | 4 + .../strategies/payout/impl/spark.strategy.ts | 4 + 79 files changed, 9693 insertions(+), 104 deletions(-) create mode 100644 src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts create mode 100644 src/integration/blockchain/firo/__tests__/firo-client.spec.ts create mode 100644 src/integration/blockchain/icp/__tests__/icp-client.spec.ts create mode 100644 src/integration/blockchain/monero/__tests__/monero-client.spec.ts create mode 100644 src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts create mode 100644 src/integration/blockchain/shared/errors/tx-broadcast.error.ts create mode 100644 src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts create mode 100644 src/integration/blockchain/solana/__tests__/solana-client.spec.ts create mode 100644 src/integration/blockchain/spark/__tests__/spark-client.spec.ts create mode 100644 src/integration/blockchain/tron/__tests__/tron-client.spec.ts create mode 100644 src/integration/blockchain/zano/__test__/zano-client.spec.ts create mode 100644 src/integration/lightning/__tests__/lightning-client.spec.ts create mode 100644 src/subdomains/supporting/payout/exceptions/__tests__/payout-broadcast.exception.spec.ts create mode 100644 src/subdomains/supporting/payout/exceptions/payout-broadcast.exception.ts create mode 100644 src/subdomains/supporting/payout/repositories/__tests__/payout-order.repository.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-arkade.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-bitcoin-testnet4.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-cardano.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-evm-chains.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-evm.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-icp.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-monero.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-solana.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-spark.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-tron.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout-zano.service.spec.ts create mode 100644 src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-retry.spec.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-strategies.spec.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/__tests__/payout-nonevm-strategies.spec.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.base.spec.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.leaf.spec.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/__tests__/payout-zano.strategy.spec.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/__tests__/payout.strategy.base.spec.ts diff --git a/src/config/config.ts b/src/config/config.ts index 56ef49ed3c..54ea8ebc99 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -782,6 +782,13 @@ export class Configuration { ], }; + payout = { + // Cap on auto-retries for a payout order that fails provably before the on-chain send call + // (gas estimation, nonce fetch, gasPrice RPC). Beyond this, a permanently failing pre-broadcast + // step (e.g. gas-estimation revert) escalates to PAYOUT_UNCERTAIN instead of retrying forever. + maxPreBroadcastRetries: +(process.env.PAYOUT_MAX_PRE_BROADCAST_RETRIES ?? 3), + }; + blockchain = { default: { user: process.env.NODE_USER, diff --git a/src/integration/blockchain/arkade/__tests__/arkade-client.spec.ts b/src/integration/blockchain/arkade/__tests__/arkade-client.spec.ts index cd2ddc419f..227d93bcfe 100644 --- a/src/integration/blockchain/arkade/__tests__/arkade-client.spec.ts +++ b/src/integration/blockchain/arkade/__tests__/arkade-client.spec.ts @@ -1,4 +1,5 @@ import { Wallet } from '@arkade-os/sdk'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { ArkadeClient } from '../arkade-client'; // Mock the SDK to provide InMemory repositories @@ -105,6 +106,100 @@ describe('ArkadeClient', () => { }); }); + // --- SEND TRANSACTION - BROADCAST BOUNDARY --- // + // wallet.sendBitcoin signs AND broadcasts atomically inside the SDK, so it must never be retried by + // this.call()'s reconnect-and-retry (which would resend an already-broadcast tx). Any failure at or + // after that call is wrapped into a TxBroadcastError; wallet resolution failures never reach + // sendBitcoin and stay plain errors. + + describe('sendTransaction - broadcast boundary', () => { + it('wraps a sendBitcoin failure into a TxBroadcastError, keeping message and cause', async () => { + const cause = new Error('unexpected rejection'); + mockWallet.sendBitcoin.mockRejectedValue(cause); + + let error: unknown; + try { + await client.sendTransaction('ark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('unexpected rejection'); + expect((error as TxBroadcastError).cause).toBe(cause); + }); + + it('wraps a non-Error rejection using String(e)', async () => { + mockWallet.sendBitcoin.mockRejectedValue('plain string rejection'); + + let error: unknown; + try { + await client.sendTransaction('ark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('plain string rejection'); + }); + + it('never resends when sendBitcoin fails with a connection-classified error (fail-closed, no double-send), but resets the cache so the next call reconnects', async () => { + mockWallet.sendBitcoin.mockRejectedValue(new Error('connection lost')); + + await expect(client.sendTransaction('ark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.sendBitcoin).toHaveBeenCalledTimes(1); // never resent within this call + + const freshWallet = { ...mockWallet, sendBitcoin: jest.fn().mockResolvedValue('tx-fresh') }; + jest.spyOn(Wallet, 'create').mockResolvedValue(freshWallet as any); + + const result = await client.sendTransaction('ark1destination', 0.5); + + expect(result).toEqual({ txid: 'tx-fresh', fee: 0 }); // next call picked up the fresh wallet + expect(freshWallet.sendBitcoin).toHaveBeenCalledTimes(1); + }); + + it('does not reset the cache when sendBitcoin fails with an unrelated error', async () => { + mockWallet.sendBitcoin.mockRejectedValue(new Error('insufficient funds')); + + await expect(client.sendTransaction('ark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + + mockWallet.sendBitcoin.mockResolvedValue('tx-retry'); + await client.sendTransaction('ark1destination', 0.5); + + // still the same wallet instance from Wallet.create - no reinitialization happened + expect(Wallet.create).toHaveBeenCalledTimes(1); + }); + + it('propagates a wallet resolution failure as a plain (non-TxBroadcastError) error and resets the cache, never reaching sendBitcoin', async () => { + jest.spyOn(Wallet, 'create').mockRejectedValue(new Error('server down')); + const freshClient = new ArkadeClient(); + + let error: unknown; + try { + await freshClient.sendTransaction('ark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('server down'); + expect(mockWallet.sendBitcoin).not.toHaveBeenCalled(); + + // reset proof: the next attempt re-initializes instead of reusing the permanently-rejected AsyncField + jest.spyOn(Wallet, 'create').mockResolvedValue(mockWallet as any); + const result = await freshClient.sendTransaction('ark1destination', 0.5); + expect(result).toEqual({ txid: 'tx-abc123', fee: 0 }); + }); + + it('wraps an empty txid from sendBitcoin into a TxBroadcastError (fail-closed silent failure)', async () => { + mockWallet.sendBitcoin.mockResolvedValue(''); + + await expect(client.sendTransaction('ark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.sendBitcoin).toHaveBeenCalledTimes(1); + }); + }); + // --- GET TRANSACTION --- // describe('getTransaction', () => { diff --git a/src/integration/blockchain/arkade/arkade-client.ts b/src/integration/blockchain/arkade/arkade-client.ts index 5d17a40ad5..abe58f8660 100644 --- a/src/integration/blockchain/arkade/arkade-client.ts +++ b/src/integration/blockchain/arkade/arkade-client.ts @@ -5,6 +5,7 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { AsyncField } from 'src/shared/utils/async-field'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient } from '../shared/util/blockchain-client'; export interface ArkadeTransaction { @@ -52,16 +53,45 @@ export class ArkadeClient extends BlockchainClient { // --- TRANSACTION METHODS --- // async sendTransaction(to: string, amount: number): Promise<{ txid: string; fee: number }> { - return this.call(async (wallet) => { - const amountSats = Math.round(amount * 1e8); + // Pre-broadcast: resolving the wallet handle never calls sendBitcoin, so a failure here is + // provably pre-broadcast. Reset the cache so the next retry gets a fresh connection attempt + // instead of repeating a possibly permanently-rejected cached promise. + let wallet: Wallet; + try { + wallet = await this.wallet; + } catch (e) { + this.wallet.reset(); + this.cachedAddress.reset(); + throw e; + } + + const amountSats = Math.round(amount * 1e8); + // Broadcast boundary: wallet.sendBitcoin signs AND broadcasts atomically inside the SDK - there + // is no separate pre-broadcast step to isolate, so any failure from here on is ambiguous (the + // tx may already be on-chain). Deliberately NOT routed through this.call()'s reconnect-and-retry: + // that wrapper re-invokes the whole operation on a connection-classified error, which for a + // broadcast would risk sending twice. Reset the cache on such an error so only the *next* call + // reconnects, without resending this one. + try { const txid = await wallet.sendBitcoin({ address: to, amount: amountSats, }); + // An empty txid from the SDK is an ambiguous silent failure - fail-closed rather than + // returning it and letting the empty id roll the order back for re-broadcast. + if (!txid) throw new TxBroadcastError('Arkade broadcast returned an empty txid'); + return { txid, fee: 0 }; - }); + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + if (e?.message?.includes('disconnected') || e?.message?.includes('connection')) { + this.wallet.reset(); + this.cachedAddress.reset(); + } + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async getTransaction(txId: string): Promise { diff --git a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts index 5c5ce1ca41..113c71cdae 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts @@ -6,6 +6,7 @@ */ import { HttpService } from 'src/shared/services/http.service'; +import { TxBroadcastError } from '../../../shared/errors/tx-broadcast.error'; import { BitcoinClient } from '../bitcoin-client'; // Mock Config and GetConfig @@ -285,6 +286,48 @@ describe('BitcoinClient', () => { expect(result).toBe('newtxid123'); }); + + // Bitcoin Core's `send` RPC builds, signs and broadcasts atomically in one node call - a + // failure here (including an HTTP-level timeout) is ambiguous, so it must surface as + // TxBroadcastError rather than a plain Error, mirroring the Cardano/Solana broadcast boundary. + it('should wrap a failure of the underlying `send` RPC call into a TxBroadcastError', async () => { + // BitcoinRpcClient.call() itself wraps any transport-level rejection into a plain Error + // prefixed with "Bitcoin RPC failed: ..." before it reaches sendMany - that + // intermediate Error is what our TxBroadcastError boundary observes and preserves as cause. + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase + mockRpcPost.mockImplementationOnce(() => Promise.reject(new Error('Invalid amount'))); + + let error: unknown; + try { + await client.sendMany(payload, 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin RPC send failed: Invalid amount'); + expect((error as TxBroadcastError).cause).toBeInstanceOf(Error); + expect(((error as TxBroadcastError).cause as Error).message).toBe('Bitcoin RPC send failed: Invalid amount'); + }); + + it('should wrap a non-Error rejection of the `send` RPC call into a TxBroadcastError via String(e)', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase + mockRpcPost.mockImplementationOnce(() => Promise.reject('node unreachable')); + + let error: unknown; + try { + await client.sendMany(payload, 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin RPC send failed: node unreachable'); + }); }); // --- testMempoolAccept() Tests --- // diff --git a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts index 545d404125..65def00ea1 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts @@ -3,6 +3,7 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpService } from 'src/shared/services/http.service'; import { BlockchainTokenBalance } from '../../shared/dto/blockchain-token-balance.dto'; import { BlockchainSignedTransactionResponse } from '../../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../../shared/errors/tx-broadcast.error'; import { CoinOnly } from '../../shared/util/blockchain-client'; import { NodeClient, NodeClientConfig } from './node-client'; @@ -69,9 +70,16 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly ...(subtractFeeFromOutputs && { subtract_fee_from_outputs: subtractFeeFromOutputs }), }; - const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); - - return result?.txid ?? ''; + // Broadcast boundary: Bitcoin Core's `send` RPC builds, signs and broadcasts in one atomic + // node-side call - there is no separate pre-broadcast step to exclude. Any failure surfacing + // from this call (including an HTTP-level timeout) is ambiguous: the node may already have + // relayed the tx before the response was lost, so it is treated as at-or-after-send. + try { + const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); + return result?.txid ?? ''; + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async sendManyFromAddress( diff --git a/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts b/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts new file mode 100644 index 0000000000..a59c7ec860 --- /dev/null +++ b/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts @@ -0,0 +1,91 @@ +/** + * Focused unit test for the CardanoClient broadcast boundary: the try/catch around the on-chain + * submit call in `sendNativeCoin` (blockFrostApi.txSubmit), which translates any failure at/after + * the broadcast call into a TxBroadcastError. Everything before that call (building/signing the + * transaction via createSignedTransactionBlockFrost) is provably pre-broadcast and must keep + * propagating as a plain error. + * + * CardanoClient's constructor wires up a real CardanoWallet from a mnemonic and other collaborators + * that need live config. To isolate JUST the changed method, we call the private prototype method + * directly via Function.prototype.call against a minimal stub `this`, following the same pattern as + * evm-client.spec.ts. + */ + +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { CardanoClient } from '../cardano-client'; + +const proto = CardanoClient.prototype as any; + +function createClientStub(txSubmit: jest.Mock): any { + const client = Object.create(CardanoClient.prototype); + + client.createSignedTransactionBlockFrost = jest.fn().mockResolvedValue('SIGNED_TX_HEX'); + client.getBlockFrostAPI = jest.fn().mockReturnValue({ txSubmit }); + + return client; +} + +describe('CardanoClient - broadcast boundary', () => { + describe('sendNativeCoin(...)', () => { + it('returns the tx hash on a successful submit', async () => { + const txSubmit = jest.fn().mockResolvedValue('TX_HASH_01'); + const client = createClientStub(txSubmit); + + const hash = await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + + expect(hash).toBe('TX_HASH_01'); + expect(txSubmit).toHaveBeenCalledWith('SIGNED_TX_HEX'); + }); + + it('wraps an Error thrown by blockFrostApi.txSubmit into a TxBroadcastError, preserving message and cause', async () => { + const submitError = new Error('UTXO already spent'); + const txSubmit = jest.fn().mockRejectedValue(submitError); + const client = createClientStub(txSubmit); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('UTXO already spent'); + expect((error as TxBroadcastError).cause).toBe(submitError); + }); + + it('wraps a non-Error rejection from blockFrostApi.txSubmit into a TxBroadcastError via String(e)', async () => { + const txSubmit = jest.fn().mockRejectedValue('BlockFrost timeout'); + const client = createClientStub(txSubmit); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('BlockFrost timeout'); + expect((error as TxBroadcastError).cause).toBe('BlockFrost timeout'); + }); + + it('does not wrap a failure from building/signing the tx (pre-broadcast, plain error propagates unchanged)', async () => { + const preBroadcastError = new Error('insufficient UTXOs'); + const client: any = Object.create(CardanoClient.prototype); + client.createSignedTransactionBlockFrost = jest.fn().mockRejectedValue(preBroadcastError); + client.getBlockFrostAPI = jest.fn(); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(preBroadcastError); // same object - not wrapped + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(client.getBlockFrostAPI).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/integration/blockchain/cardano/cardano-client.ts b/src/integration/blockchain/cardano/cardano-client.ts index 57f72821b5..2949bb884d 100644 --- a/src/integration/blockchain/cardano/cardano-client.ts +++ b/src/integration/blockchain/cardano/cardano-client.ts @@ -19,6 +19,7 @@ import { } from '@emurgo/cardano-serialization-lib-nodejs'; import blake from 'blakejs'; import { Config } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpRequestConfig, HttpService } from 'src/shared/services/http.service'; import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; @@ -156,7 +157,12 @@ export class CardanoClient extends BlockchainClient { const signedTransactionHex = await this.createSignedTransactionBlockFrost(wallet, toAddress, amount); const blockFrostApi = this.getBlockFrostAPI(); - return blockFrostApi.txSubmit(signedTransactionHex); + + try { + return await blockFrostApi.txSubmit(signedTransactionHex); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } private async createSignedTransactionBlockFrost( diff --git a/src/integration/blockchain/firo/__tests__/firo-client.spec.ts b/src/integration/blockchain/firo/__tests__/firo-client.spec.ts new file mode 100644 index 0000000000..a40eef0436 --- /dev/null +++ b/src/integration/blockchain/firo/__tests__/firo-client.spec.ts @@ -0,0 +1,248 @@ +/** + * Unit tests for FiroClient's broadcast boundary. + * + * Firo has no atomic Bitcoin Core `send` RPC, so the client builds+signs locally + * (createrawtransaction/signrawtransaction - pre-broadcast, plain Error) and only broadcasts via a + * final sendrawtransaction call (mintspark is the exception: it builds+signs+broadcasts atomically + * in one node-side call, like Bitcoin Core's `send`). Only a failure at/after that final call is + * ambiguous and must surface as TxBroadcastError. + */ + +import { HttpService } from 'src/shared/services/http.service'; +import { TxBroadcastError } from '../../shared/errors/tx-broadcast.error'; +import { FiroClient } from '../firo-client'; + +jest.mock('src/config/config', () => { + const mockConfig = { + blockchain: { + firo: { + user: 'testuser', + password: 'testpass', + walletPassword: 'walletpass123', + walletAddress: 'tWalletAddress', + allowUnconfirmedUtxos: true, + transparentTxSize: 225, + sparkMintTxSize: 480, + inputSize: 225, + outputSize: 34, + txOverhead: 10, + }, + }, + payment: { + firoAddress: 'tPaymentAddress', + }, + }; + return { + Config: mockConfig, + GetConfig: () => mockConfig, + }; +}); + +describe('FiroClient - broadcast boundary', () => { + let client: FiroClient; + let mockRpcPost: jest.Mock; + let lastRpcCalls: Array<{ method: string; params: any[] }>; + + beforeEach(() => { + lastRpcCalls = []; + + mockRpcPost = jest.fn().mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + lastRpcCalls.push({ method: parsed.method, params: parsed.params }); + + switch (parsed.method) { + case 'walletpassphrase': + return Promise.resolve({ result: null, error: null, id: 'test' }); + case 'listunspent': + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + case 'createrawtransaction': + return Promise.resolve({ result: 'rawtxhex', error: null, id: 'test' }); + case 'signrawtransaction': + return Promise.resolve({ result: { hex: 'signedtxhex', complete: true }, error: null, id: 'test' }); + case 'sendrawtransaction': + return Promise.resolve({ result: 'broadcasttxid123', error: null, id: 'test' }); + case 'mintspark': + return Promise.resolve({ result: ['minttxid123'], error: null, id: 'test' }); + default: + return Promise.resolve({ result: null, error: null, id: 'test' }); + } + }); + + const mockHttpService = { + post: mockRpcPost, + get: jest.fn(), + } as unknown as jest.Mocked; + + client = new FiroClient(mockHttpService, 'http://localhost:8000'); + }); + + describe('sendMany(...) -> buildSignAndBroadcast(...)', () => { + it('returns the txid on a successful sendrawtransaction call', async () => { + const result = await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); + + expect(result).toBe('broadcasttxid123'); + }); + + it('wraps a failure of the final sendrawtransaction call into a TxBroadcastError', async () => { + // BitcoinRpcClient.call() (shared by Bitcoin/Firo) wraps any transport-level rejection into + // a plain Error prefixed with "Bitcoin RPC failed: ..." before it reaches + // buildSignAndBroadcast - that intermediate Error is observed and preserved as cause. + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'sendrawtransaction') return Promise.reject(new Error('tx-fee-not-met')); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + if (parsed.method === 'listunspent') + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + if (parsed.method === 'createrawtransaction') + return Promise.resolve({ result: 'rawtxhex', error: null, id: 'test' }); + if (parsed.method === 'signrawtransaction') + return Promise.resolve({ result: { hex: 'signedtxhex', complete: true }, error: null, id: 'test' }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin RPC sendrawtransaction failed: tx-fee-not-met'); + }); + + it('does not wrap a pre-broadcast signing failure (plain Error propagates unchanged)', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'signrawtransaction') + return Promise.resolve({ result: { hex: '', complete: false }, error: null, id: 'test' }); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + if (parsed.method === 'listunspent') + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + if (parsed.method === 'createrawtransaction') + return Promise.resolve({ result: 'rawtxhex', error: null, id: 'test' }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('Failed to sign Firo transaction'); + }); + + it('does not wrap a pre-broadcast UTXO-fetch failure (plain Error propagates unchanged)', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'listunspent') return Promise.reject(new Error('connection timeout')); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Bitcoin RPC listunspent failed: connection timeout'); + }); + }); + + describe('mintSpark(...)', () => { + it('returns the first txid on a successful mintspark call', async () => { + const result = await client.mintSpark([{ address: 'sparkAddr1', amount: 1 }]); + + expect(result).toBe('minttxid123'); + }); + + it('wraps a failure of the mintspark call into a TxBroadcastError', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'mintspark') return Promise.reject(new Error('insufficient funds')); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + if (parsed.method === 'listunspent') + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.mintSpark([{ address: 'sparkAddr1', amount: 1 }]); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin RPC mintspark failed: insufficient funds'); + }); + + it('does not wrap an RPC-successful-but-empty mintspark result (deterministic negative ack, plain Error)', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'mintspark') return Promise.resolve({ result: [], error: null, id: 'test' }); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + if (parsed.method === 'listunspent') + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.mintSpark([{ address: 'sparkAddr1', amount: 1 }]); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('mintspark returned no transaction IDs'); + }); + + it('does not wrap a pre-broadcast no-UTXO failure (plain Error propagates unchanged)', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'listunspent') return Promise.resolve({ result: [], error: null, id: 'test' }); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.mintSpark([{ address: 'sparkAddr1', amount: 1 }]); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('No non-deposit addresses with UTXOs available'); + }); + }); +}); diff --git a/src/integration/blockchain/firo/firo-client.ts b/src/integration/blockchain/firo/firo-client.ts index f3442dbf76..dd3697d50d 100644 --- a/src/integration/blockchain/firo/firo-client.ts +++ b/src/integration/blockchain/firo/firo-client.ts @@ -3,6 +3,7 @@ import { HttpService } from 'src/shared/services/http.service'; import { BitcoinBasedClient, TestMempoolResult } from '../bitcoin/node/bitcoin-based-client'; import { UTXO } from '../bitcoin/node/dto/bitcoin-transaction.dto'; import { Block, NodeClientConfig } from '../bitcoin/node/node-client'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { FiroRawTransaction } from './rpc'; /** @@ -189,7 +190,13 @@ export class FiroClient extends BitcoinBasedClient { throw new Error('Failed to sign Firo transaction'); } - return this.callNode(() => this.rpc.call('sendrawtransaction', [signedResult.hex]), true); + // Broadcast boundary: sendrawtransaction is the actual network relay - everything above + // (createrawtransaction/signrawtransaction) is local/pre-broadcast and stays a plain Error. + try { + return await this.callNode(() => this.rpc.call('sendrawtransaction', [signedResult.hex]), true); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } // Delegates to sendMany which uses manual coin selection from the liquidity and payment addresses. @@ -240,11 +247,21 @@ export class FiroClient extends BitcoinBasedClient { }; } - const mintTxIds = await this.callNode( - () => this.rpc.call('mintspark', [sparkAddresses, false, fromAddresses]), - true, - ); + // Broadcast boundary: mintspark builds, signs and broadcasts atomically in one node-side + // call (like Bitcoin Core's `send`) - a failure here is at-or-after the broadcast. + let mintTxIds: string[]; + try { + mintTxIds = await this.callNode( + () => this.rpc.call('mintspark', [sparkAddresses, false, fromAddresses]), + true, + ); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } + // The RPC call itself succeeded (no network ambiguity) but returned no txid - the node is + // explicitly telling us nothing was minted, so this is a provable pre-broadcast-equivalent + // failure and stays a plain Error (self-heal / rollback is safe, nothing was sent). if (!mintTxIds?.length) { throw new Error('mintspark returned no transaction IDs'); } diff --git a/src/integration/blockchain/icp/__tests__/icp-client.spec.ts b/src/integration/blockchain/icp/__tests__/icp-client.spec.ts new file mode 100644 index 0000000000..66544c1f4e --- /dev/null +++ b/src/integration/blockchain/icp/__tests__/icp-client.spec.ts @@ -0,0 +1,202 @@ +/** + * Focused unit test for the InternetComputerClient broadcast boundary: the try/catch tightly + * wrapping the actual update call (ledger.icrc1Transfer / tokenLedger.transfer), which translates + * any failure at/after that ambiguous IC update call into a TxBroadcastError. Building the transfer + * request (Principal parsing, amount conversion) happens BEFORE the try and must keep propagating + * as a plain, provably pre-broadcast error - same boundary shape as cardano-client.spec.ts / + * solana-client.spec.ts. + * + * InternetComputerClient's constructor wires up a real wallet/agent from a seed and other + * collaborators that need live config. To isolate JUST the changed methods, we call the private + * prototype methods directly via Function.prototype.call against a minimal stub `this`, following + * the same pattern as evm/cardano/solana-client.spec.ts. + */ + +import { IcpLedgerCanister } from '@dfinity/ledger-icp'; +import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { InternetComputerClient } from '../icp-client'; + +const proto = InternetComputerClient.prototype as any; + +// Real, checksum-valid IC principal texts (management canister / ICP ledger canister). No network +// call is made - Principal.fromText only needs a well-formed textual principal to parse locally. +const VALID_PRINCIPAL = 'aaaaa-aa'; +const VALID_CANISTER_ID = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; + +function createClientStub(host = 'https://icp-host.example'): any { + const client = Object.create(InternetComputerClient.prototype); + client.host = host; + return client; +} + +function createWalletStub(): any { + return { getAgent: jest.fn().mockReturnValue({ agent: true }) }; +} + +describe('InternetComputerClient - broadcast boundary', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('returns the block index as a string on a successful icrc1Transfer', async () => { + const icrc1Transfer = jest.fn().mockResolvedValue(42n); + jest.spyOn(IcpLedgerCanister, 'create').mockReturnValue({ icrc1Transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + const result = await proto.sendNativeCoin.call(client, wallet, VALID_PRINCIPAL, 1); + + expect(result).toBe('42'); + expect(icrc1Transfer).toHaveBeenCalledWith({ + to: { owner: expect.anything(), subaccount: [] }, + amount: 100000000n, + }); + }); + + it('wraps an Error thrown by icrc1Transfer into a TxBroadcastError, preserving message and cause', async () => { + const sendError = new Error('IC boundary node timeout'); + const icrc1Transfer = jest.fn().mockRejectedValue(sendError); + jest.spyOn(IcpLedgerCanister, 'create').mockReturnValue({ icrc1Transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, VALID_PRINCIPAL, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('IC boundary node timeout'); + expect((error as TxBroadcastError).cause).toBe(sendError); + }); + + it('wraps a non-Error rejection from icrc1Transfer into a TxBroadcastError via String(e)', async () => { + const icrc1Transfer = jest.fn().mockRejectedValue('reject-timeout'); + jest.spyOn(IcpLedgerCanister, 'create').mockReturnValue({ icrc1Transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, VALID_PRINCIPAL, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('reject-timeout'); + expect((error as TxBroadcastError).cause).toBe('reject-timeout'); + }); + + it('does not wrap a failure from building the transfer request (pre-broadcast, plain error propagates unchanged)', async () => { + const icrc1Transfer = jest.fn(); + jest.spyOn(IcpLedgerCanister, 'create').mockReturnValue({ icrc1Transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'not-a-valid-principal', 1); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(icrc1Transfer).not.toHaveBeenCalled(); // broadcast never reached + }); + }); + + describe('sendToken(...)', () => { + const token = createCustomAsset({ chainId: VALID_CANISTER_ID, decimals: 8, uniqueName: 'ICP/ckBTC' }); + + it('returns "canisterId:blockIndex" on a successful transfer', async () => { + const transfer = jest.fn().mockResolvedValue(7n); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + const result = await proto.sendToken.call(client, wallet, VALID_PRINCIPAL, token, 1); + + expect(result).toBe(`${VALID_CANISTER_ID}:7`); + }); + + it('wraps an Error thrown by transfer into a TxBroadcastError, preserving message and cause', async () => { + const sendError = new Error('IC boundary node timeout'); + const transfer = jest.fn().mockRejectedValue(sendError); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, VALID_PRINCIPAL, token, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('IC boundary node timeout'); + expect((error as TxBroadcastError).cause).toBe(sendError); + }); + + it('wraps a non-Error rejection from transfer into a TxBroadcastError via String(e)', async () => { + const transfer = jest.fn().mockRejectedValue('reject-timeout'); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, VALID_PRINCIPAL, token, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('reject-timeout'); + }); + + it('does not wrap a missing canister ID (pre-broadcast, plain error propagates unchanged)', async () => { + const transfer = jest.fn(); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + const tokenWithoutChainId = createCustomAsset({ chainId: undefined, uniqueName: 'NO_CHAIN_ID' }); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, VALID_PRINCIPAL, tokenWithoutChainId, 1); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('No canister ID for token NO_CHAIN_ID'); + expect(transfer).not.toHaveBeenCalled(); + }); + + it('does not wrap a failure from building the transfer request (pre-broadcast, plain error propagates unchanged)', async () => { + const transfer = jest.fn(); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'not-a-valid-principal', token, 1); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(transfer).not.toHaveBeenCalled(); // broadcast never reached + }); + }); +}); diff --git a/src/integration/blockchain/icp/icp-client.ts b/src/integration/blockchain/icp/icp-client.ts index 42885101db..8b3c7dbfa8 100644 --- a/src/integration/blockchain/icp/icp-client.ts +++ b/src/integration/blockchain/icp/icp-client.ts @@ -3,6 +3,7 @@ import { IcpLedgerCanister } from '@dfinity/ledger-icp'; import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; import { Principal } from '@dfinity/principal'; import { Config, GetConfig } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { HttpService } from 'src/shared/services/http.service'; @@ -368,15 +369,24 @@ export class InternetComputerClient extends BlockchainClient { const agent = wallet.getAgent(this.host); const ledger = IcpLedgerCanister.create({ agent }); - const blockIndex = await ledger.icrc1Transfer({ + // Pre-broadcast: build/validate the request (Principal parsing, amount conversion) OUTSIDE the + // broadcast boundary so a malformed address fails as a plain, provably-pre-send error. + const transferArgs: Parameters[0] = { to: { owner: Principal.fromText(toAddress), subaccount: [], }, amount: InternetComputerUtil.toSmallestUnit(amount), - }); + }; - return blockIndex.toString(); + // Broadcast boundary: an ICP update call is ambiguous once sent - the IC boundary node may have + // already accepted/routed it before a network error surfaces here. + try { + const blockIndex = await ledger.icrc1Transfer(transferArgs); + return blockIndex.toString(); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } // --- Send token --- @@ -415,15 +425,22 @@ export class InternetComputerClient extends BlockchainClient { canisterId: Principal.fromText(canisterId), }); - const blockIndex = await tokenLedger.transfer({ + // Pre-broadcast: build/validate the request OUTSIDE the broadcast boundary (see sendNativeCoin). + const transferArgs: Parameters[0] = { to: { owner: Principal.fromText(toAddress), subaccount: [], }, amount: InternetComputerUtil.toSmallestUnit(amount, token.decimals), - }); + }; - return `${canisterId}:${blockIndex}`; + // Broadcast boundary: see sendNativeCoin. + try { + const blockIndex = await tokenLedger.transfer(transferArgs); + return `${canisterId}:${blockIndex}`; + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } // --- ICRC-2 Approve/TransferFrom --- diff --git a/src/integration/blockchain/monero/__tests__/monero-client.spec.ts b/src/integration/blockchain/monero/__tests__/monero-client.spec.ts new file mode 100644 index 0000000000..76c960ada5 --- /dev/null +++ b/src/integration/blockchain/monero/__tests__/monero-client.spec.ts @@ -0,0 +1,134 @@ +/** + * Unit tests for MoneroClient's broadcast boundary. + * + * The wallet RPC's 'transfer' method builds, signs and relays a Monero transaction atomically in + * one call - there is no separate pre-broadcast step to exclude. A failure of the HTTP call + * itself, an RPC-level error field, or a missing result are all ambiguous (the wallet may have + * already relayed before the response was lost/rejected) and must surface as TxBroadcastError, + * mirroring the Solana sendTransaction boundary (result.error / empty hash -> TxBroadcastError). + */ + +import { HttpService } from 'src/shared/services/http.service'; +import { PayoutGroup } from 'src/subdomains/supporting/payout/services/base/payout-bitcoin-based.service'; +import { TxBroadcastError } from '../../shared/errors/tx-broadcast.error'; +import { MoneroClient } from '../monero-client'; + +jest.mock('src/config/config', () => { + const mockConfig = { + blockchain: { + monero: { + node: { url: 'https://monero-node.test' }, + rpc: { url: 'https://monero-rpc.test' }, + walletAddress: 'MONERO_WALLET_ADDR', + certificate: undefined, + }, + }, + }; + return { + Config: mockConfig, + GetConfig: () => mockConfig, + }; +}); + +describe('MoneroClient - broadcast boundary', () => { + let client: MoneroClient; + let mockPost: jest.Mock; + + beforeEach(() => { + mockPost = jest.fn(); + + const mockHttpService = { + post: mockPost, + get: jest.fn(), + } as unknown as jest.Mocked; + + client = new MoneroClient(mockHttpService); + }); + + const payout: PayoutGroup = [{ addressTo: 'MONERO_DEST_ADDR', amount: 1.5 }]; + + describe('sendTransfers(...)', () => { + it('returns the mapped transfer on a successful RPC response', async () => { + mockPost.mockResolvedValueOnce({ + result: { amount: 1500000000000, fee: 10000000000, tx_hash: 'TX_HASH_01' }, + }); + + const result = await client.sendTransfers(payout); + + expect(result.txid).toBe('TX_HASH_01'); + }); + + it('wraps a failure of the underlying HTTP call into a TxBroadcastError', async () => { + const httpError = new Error('socket hang up'); + mockPost.mockRejectedValueOnce(httpError); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('socket hang up'); + expect((error as TxBroadcastError).cause).toBe(httpError); + }); + + it('wraps a non-Error rejection into a TxBroadcastError via String(e)', async () => { + mockPost.mockRejectedValueOnce('gateway timeout'); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('gateway timeout'); + }); + + it('wraps an RPC-level error field (HTTP 200, JSON-RPC error) into a TxBroadcastError', async () => { + mockPost.mockResolvedValueOnce({ error: { code: -18, message: 'Failed to send tx' } }); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Failed to send tx'); + }); + + it('wraps a response with neither result nor error into a TxBroadcastError', async () => { + mockPost.mockResolvedValueOnce({}); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('No result after send transfer'); + }); + + it('wraps a malformed null body (throwing while reading result.error) into a TxBroadcastError, staying fail-closed', async () => { + // A null/undefined body would make mapSendTransfer throw a plain TypeError; since it now runs + // inside the boundary, that becomes a TxBroadcastError instead of a self-healing plain error. + mockPost.mockResolvedValueOnce(null); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + }); + }); +}); diff --git a/src/integration/blockchain/monero/monero-client.ts b/src/integration/blockchain/monero/monero-client.ts index 4937a807b9..b83895a989 100644 --- a/src/integration/blockchain/monero/monero-client.ts +++ b/src/integration/blockchain/monero/monero-client.ts @@ -7,6 +7,7 @@ import { Util } from 'src/shared/utils/util'; import { PayoutGroup } from 'src/subdomains/supporting/payout/services/base/payout-bitcoin-based.service'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient, CoinOnly } from '../shared/util/blockchain-client'; import { AddressResultDto, @@ -239,9 +240,14 @@ export class MoneroClient extends BlockchainClient implements CoinOnly { return this.sendTransfers([{ addressTo: destinationAddress, amount }]); } + // Broadcast boundary: the wallet RPC's 'transfer' method builds, signs and relays the tx + // atomically in one call - there is no separate pre-broadcast step. A failure of the HTTP call + // itself, an RPC-level error field, or a missing result are all ambiguous (the wallet may have + // already relayed before the response was lost/rejected), mirroring the Solana sendTransaction + // boundary (result.error / empty hash -> TxBroadcastError). async sendTransfers(payout: PayoutGroup): Promise { - return this.http - .post( + try { + const result = await this.http.post( `${Config.blockchain.monero.rpc.url}/json_rpc`, { method: 'transfer', @@ -252,13 +258,21 @@ export class MoneroClient extends BlockchainClient implements CoinOnly { }, }, this.httpConfig(), - ) - .then((r) => this.mapSendTransfer(r)); + ); + + // Response mapping stays inside the boundary: a malformed/empty body throwing while reading + // result.error would otherwise be a plain error and self-heal a possibly-relayed transfer. + return this.mapSendTransfer(result); + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } private mapSendTransfer(sendTransferResult: GetSendTransferResultDto): MoneroTransferDto { - if (sendTransferResult.error) throw new Error(sendTransferResult.error.message); - if (!sendTransferResult.result) throw new Error('No result after send transfer'); + if (sendTransferResult.error) + throw new TxBroadcastError(sendTransferResult.error.message, { cause: sendTransferResult.error }); + if (!sendTransferResult.result) throw new TxBroadcastError('No result after send transfer'); return this.convertTransferAuToXmr({ amount: sendTransferResult.result.amount, diff --git a/src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts b/src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts new file mode 100644 index 0000000000..4bb5ac45c4 --- /dev/null +++ b/src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts @@ -0,0 +1,20 @@ +import { TxBroadcastError } from '../tx-broadcast.error'; + +describe('TxBroadcastError', () => { + it('sets message and name', () => { + const error = new TxBroadcastError('broadcast failed'); + + expect(error.message).toBe('broadcast failed'); + expect(error.name).toBe('TxBroadcastError'); + expect(error).toBeInstanceOf(Error); + expect(error.cause).toBeUndefined(); + }); + + it('carries the cause through to the Error options', () => { + const cause = new Error('underlying RPC error'); + + const error = new TxBroadcastError('broadcast failed', { cause }); + + expect(error.cause).toBe(cause); + }); +}); diff --git a/src/integration/blockchain/shared/errors/tx-broadcast.error.ts b/src/integration/blockchain/shared/errors/tx-broadcast.error.ts new file mode 100644 index 0000000000..292a4968e7 --- /dev/null +++ b/src/integration/blockchain/shared/errors/tx-broadcast.error.ts @@ -0,0 +1,9 @@ +// Generic marker for a failure of the actual on-chain send call (wallet.sendTransaction / +// contract.transfer). Deliberately not payout-specific - this client is shared by dex, payin and +// faucet callers too, which keep catching plain Error via catch(e) and logging e.message. +export class TxBroadcastError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'TxBroadcastError'; + } +} diff --git a/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts new file mode 100644 index 0000000000..bfb1b5695d --- /dev/null +++ b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts @@ -0,0 +1,215 @@ +/** + * Focused unit tests for the EvmClient broadcast boundary: the two try/catch blocks around the + * actual on-chain send calls in `sendNativeCoin` (wallet.sendTransaction) and `sendToken` + * (contract.transfer), which translate any failure at/after the broadcast call into a + * TxBroadcastError. + * + * EvmClient is a large abstract class whose constructor wires up real ethers.js collaborators + * (StaticJsonRpcProvider, Wallet, AlphaRouter, ...) and pulls in the rest of its considerable + * surface (Uniswap swaps, Alchemy history, ...). None of that is relevant here and constructing a + * real instance would need live network config. To isolate JUST the two changed methods, we call + * the (protected/private) prototype methods directly via Function.prototype.call against a minimal + * stub `this`: a plain object created with `Object.create(EvmClient.prototype)` (so it still has + * EvmClient's prototype chain) with only the handful of collaborator methods the two methods under + * test depend on (getCurrentGasForCoinTransaction, getTokenGasLimitForContact, + * getRecommendedGasPrice, getNonce, setNonce, getTokenByContract) stubbed as own properties, which + * shadow the real prototype implementations. This exercises the real, unmodified method bodies + * without needing a live provider/wallet/router. + */ + +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { EvmClient } from '../evm-client'; + +const proto = EvmClient.prototype as any; + +interface ClientStub { + getCurrentGasForCoinTransaction: jest.Mock; + getTokenGasLimitForContact: jest.Mock; + getRecommendedGasPrice: jest.Mock; + getNonce: jest.Mock; + setNonce: jest.Mock; + getTokenByContract: jest.Mock; +} + +function createClientStub(): ClientStub { + const client = Object.create(EvmClient.prototype) as ClientStub; + + client.getCurrentGasForCoinTransaction = jest.fn().mockResolvedValue(21000); + client.getTokenGasLimitForContact = jest.fn().mockResolvedValue(65000); + client.getRecommendedGasPrice = jest.fn().mockResolvedValue(1_000_000_000); + client.getNonce = jest.fn().mockResolvedValue(5); + client.setNonce = jest.fn(); + client.getTokenByContract = jest.fn().mockResolvedValue({ decimals: 6 }); + + return client; +} + +describe('EvmClient - broadcast boundary', () => { + describe('sendNativeCoin(...)', () => { + it('broadcasts successfully, defaults to the current nonce and advances the cached nonce', async () => { + const client = createClientStub(); + const sendTransaction = jest.fn().mockResolvedValue({ hash: 'TX_HASH_01' }); + const wallet = { address: 'FROM_ADDR', sendTransaction }; + + const hash = await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1); + + expect(hash).toBe('TX_HASH_01'); + expect(sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'FROM_ADDR', + to: 'TO_ADDR', + nonce: 5, + gasPrice: 1_000_000_000, + gasLimit: 21000, + }), + ); + expect(client.setNonce).toHaveBeenCalledWith('FROM_ADDR', 6); // txNonce(5) >= currentNonce(5) + }); + + it('reuses an explicit lower nonce (speedup/retry) without advancing the cached nonce', async () => { + const client = createClientStub(); + const sendTransaction = jest.fn().mockResolvedValue({ hash: 'TX_HASH_02' }); + const wallet = { address: 'FROM_ADDR', sendTransaction }; + + const hash = await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1, 3); + + expect(hash).toBe('TX_HASH_02'); + expect(sendTransaction).toHaveBeenCalledWith(expect.objectContaining({ nonce: 3 })); + expect(client.setNonce).not.toHaveBeenCalled(); // txNonce(3) < currentNonce(5) + }); + + it('wraps an Error thrown by wallet.sendTransaction into a TxBroadcastError, preserving message and cause', async () => { + const client = createClientStub(); + const rpcError = new Error('nonce too low'); + const wallet = { address: 'FROM_ADDR', sendTransaction: jest.fn().mockRejectedValue(rpcError) }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('nonce too low'); + expect((error as TxBroadcastError).cause).toBe(rpcError); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('wraps a non-Error rejection from wallet.sendTransaction into a TxBroadcastError via String(e)', async () => { + const client = createClientStub(); + const wallet = { address: 'FROM_ADDR', sendTransaction: jest.fn().mockRejectedValue('RPC timeout') }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('RPC timeout'); + expect((error as TxBroadcastError).cause).toBe('RPC timeout'); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('treats an empty tx hash as a broadcast-boundary failure (fail-closed: the send call was reached)', async () => { + const client = createClientStub(); + const sendTransaction = jest.fn().mockResolvedValue({ hash: '' }); + const wallet = { address: 'FROM_ADDR', sendTransaction }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Broadcast returned an empty tx hash'); + expect(client.setNonce).not.toHaveBeenCalled(); // never advance the nonce on an unconfirmed broadcast + }); + }); + + describe('sendToken(...)', () => { + it('broadcasts successfully, defaults to the current nonce and advances the cached nonce', async () => { + const client = createClientStub(); + const transfer = jest.fn().mockResolvedValue({ hash: 'TX_HASH_03' }); + const contract = { transfer }; + + const hash = await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1); + + expect(hash).toBe('TX_HASH_03'); + expect(transfer).toHaveBeenCalledWith( + 'TO_ADDR', + expect.anything(), + expect.objectContaining({ nonce: 5, gasPrice: 1_000_000_000, gasLimit: 65000 }), + ); + expect(client.setNonce).toHaveBeenCalledWith('FROM_ADDR', 6); // txNonce(5) >= currentNonce(5) + }); + + it('reuses an explicit lower nonce (speedup/retry) without advancing the cached nonce', async () => { + const client = createClientStub(); + const transfer = jest.fn().mockResolvedValue({ hash: 'TX_HASH_04' }); + const contract = { transfer }; + + const hash = await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1, 3); + + expect(hash).toBe('TX_HASH_04'); + expect(transfer).toHaveBeenCalledWith('TO_ADDR', expect.anything(), expect.objectContaining({ nonce: 3 })); + expect(client.setNonce).not.toHaveBeenCalled(); // txNonce(3) < currentNonce(5) + }); + + it('wraps an Error thrown by contract.transfer into a TxBroadcastError, preserving message and cause', async () => { + const client = createClientStub(); + const rpcError = new Error('replacement transaction underpriced'); + const contract = { transfer: jest.fn().mockRejectedValue(rpcError) }; + + let error: unknown; + try { + await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('replacement transaction underpriced'); + expect((error as TxBroadcastError).cause).toBe(rpcError); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('wraps a non-Error rejection from contract.transfer into a TxBroadcastError via String(e)', async () => { + const client = createClientStub(); + const contract = { transfer: jest.fn().mockRejectedValue('RPC timeout') }; + + let error: unknown; + try { + await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('RPC timeout'); + expect((error as TxBroadcastError).cause).toBe('RPC timeout'); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('treats an empty tx hash as a broadcast-boundary failure (fail-closed: the send call was reached)', async () => { + const client = createClientStub(); + const transfer = jest.fn().mockResolvedValue({ hash: '' }); + const contract = { transfer }; + + let error: unknown; + try { + await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Broadcast returned an empty tx hash'); + expect(client.setNonce).not.toHaveBeenCalled(); // never advance the nonce on an unconfirmed broadcast + }); + }); +}); diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 5530a984d6..9e03aa8c54 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -10,6 +10,7 @@ import { Contract, BigNumber as EthersNumber, ethers } from 'ethers'; import { hashMessage } from 'ethers/lib/utils'; import { AlchemyService, AssetTransfersParams } from 'src/integration/alchemy/services/alchemy.service'; import { BlockscoutService } from 'src/integration/blockchain/shared/blockscout/blockscout.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import ERC1271_ABI from 'src/integration/blockchain/shared/evm/abi/erc1271.abi.json'; import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; import SIGNATURE_TRANSFER_ABI from 'src/integration/blockchain/shared/evm/abi/signature-transfer.abi.json'; @@ -826,14 +827,21 @@ export abstract class EvmClient extends BlockchainClient { const currentNonce = await this.getNonce(fromAddress); const txNonce = nonce ?? currentNonce; - const tx = await wallet.sendTransaction({ - from: fromAddress, - to: toAddress, - value: EvmUtil.toWeiAmount(amount), - nonce: txNonce, - gasPrice, - gasLimit, - }); + let tx: ethers.providers.TransactionResponse; + try { + tx = await wallet.sendTransaction({ + from: fromAddress, + to: toAddress, + value: EvmUtil.toWeiAmount(amount), + nonce: txNonce, + gasPrice, + gasLimit, + }); + + if (!tx?.hash) throw new Error('Broadcast returned an empty tx hash'); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } if (txNonce >= currentNonce) this.setNonce(fromAddress, txNonce + 1); @@ -863,7 +871,14 @@ export abstract class EvmClient extends BlockchainClient { const currentNonce = await this.getNonce(fromAddress); const txNonce = nonce ?? currentNonce; - const tx = await contract.transfer(toAddress, targetAmount, { gasPrice, gasLimit, nonce: txNonce }); + let tx: ethers.ContractTransaction; + try { + tx = await contract.transfer(toAddress, targetAmount, { gasPrice, gasLimit, nonce: txNonce }); + + if (!tx?.hash) throw new Error('Broadcast returned an empty tx hash'); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } if (txNonce >= currentNonce) this.setNonce(fromAddress, txNonce + 1); diff --git a/src/integration/blockchain/solana/__tests__/solana-client.spec.ts b/src/integration/blockchain/solana/__tests__/solana-client.spec.ts new file mode 100644 index 0000000000..626ce04c68 --- /dev/null +++ b/src/integration/blockchain/solana/__tests__/solana-client.spec.ts @@ -0,0 +1,125 @@ +/** + * Focused unit test for the SolanaClient broadcast boundary: `sendTransaction` signs and serializes + * the transaction locally (pre-broadcast, provably safe to treat as a plain error), then calls + * `sendSignedTransaction` to relay it over RPC with skipPreflight - once that call has been made, a + * returned error is ambiguous (the node may already have accepted/relayed the tx), so it is + * translated into a TxBroadcastError. + * + * SolanaClient's constructor wires up a real Connection/SolanaWallet that need live config. To + * isolate JUST the changed method, we call the private prototype method directly via + * Function.prototype.call against a minimal stub `this`, following the same pattern as + * evm-client.spec.ts. + */ + +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { SolanaClient } from '../solana-client'; + +const proto = SolanaClient.prototype as any; + +function createClientStub(sendSignedTransaction: jest.Mock): any { + const client = Object.create(SolanaClient.prototype); + + client.sendSignedTransaction = sendSignedTransaction; + + return client; +} + +function createTransactionStub(): any { + return { serialize: jest.fn().mockReturnValue(Buffer.from('DEADBEEF', 'hex')) }; +} + +describe('SolanaClient - broadcast boundary', () => { + describe('sendTransaction(...)', () => { + it('signs, serializes and returns the tx hash when the RPC call succeeds', async () => { + const sendSignedTransaction = jest.fn().mockResolvedValue({ hash: 'TX_HASH_01' }); + const client = createClientStub(sendSignedTransaction); + const wallet = { signTransaction: jest.fn() }; + const transaction = createTransactionStub(); + + const hash = await proto.sendTransaction.call(client, wallet, transaction); + + expect(hash).toBe('TX_HASH_01'); + expect(wallet.signTransaction).toHaveBeenCalledWith(transaction); + expect(sendSignedTransaction).toHaveBeenCalledWith('deadbeef'); + }); + + it('wraps a missing tx hash (HTTP 200, no result.error) into a TxBroadcastError (fail-closed: request already sent)', async () => { + const result = { hash: undefined }; + const sendSignedTransaction = jest.fn().mockResolvedValue(result); + const client = createClientStub(sendSignedTransaction); + const wallet = { signTransaction: jest.fn() }; + const transaction = createTransactionStub(); + + let error: unknown; + try { + await proto.sendTransaction.call(client, wallet, transaction); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Broadcast returned an empty tx hash'); + expect((error as TxBroadcastError).cause).toBe(result); + }); + + it('wraps an empty-string tx hash (HTTP 200, no result.error) into a TxBroadcastError (fail-closed: request already sent)', async () => { + const result = { hash: '' }; + const sendSignedTransaction = jest.fn().mockResolvedValue(result); + const client = createClientStub(sendSignedTransaction); + const wallet = { signTransaction: jest.fn() }; + const transaction = createTransactionStub(); + + let error: unknown; + try { + await proto.sendTransaction.call(client, wallet, transaction); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Broadcast returned an empty tx hash'); + }); + + it('wraps an RPC error returned by sendSignedTransaction into a TxBroadcastError (fail-closed: request already sent)', async () => { + const rpcError = { code: -32002, message: 'Transaction simulation failed' }; + const sendSignedTransaction = jest.fn().mockResolvedValue({ error: rpcError }); + const client = createClientStub(sendSignedTransaction); + const wallet = { signTransaction: jest.fn() }; + const transaction = createTransactionStub(); + + let error: unknown; + try { + await proto.sendTransaction.call(client, wallet, transaction); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Transaction simulation failed'); + expect((error as TxBroadcastError).cause).toBe(rpcError); + }); + + it('does not wrap a failure from wallet.signTransaction (local, pre-broadcast, plain error propagates unchanged)', async () => { + const signError = new Error('invalid keypair'); + const sendSignedTransaction = jest.fn(); + const client = createClientStub(sendSignedTransaction); + const wallet = { + signTransaction: jest.fn().mockImplementation(() => { + throw signError; + }), + }; + const transaction = createTransactionStub(); + + let error: unknown; + try { + await proto.sendTransaction.call(client, wallet, transaction); + } catch (e) { + error = e; + } + + expect(error).toBe(signError); // same object - not wrapped + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(sendSignedTransaction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/integration/blockchain/solana/solana-client.ts b/src/integration/blockchain/solana/solana-client.ts index 482166bc1d..23301669fe 100644 --- a/src/integration/blockchain/solana/solana-client.ts +++ b/src/integration/blockchain/solana/solana-client.ts @@ -1,6 +1,7 @@ import * as SolanaToken from '@solana/spl-token'; import * as Solana from '@solana/web3.js'; import { Config, GetConfig } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpService } from 'src/shared/services/http.service'; import { AsyncCache } from 'src/shared/utils/async-cache'; @@ -128,8 +129,13 @@ export class SolanaClient extends BlockchainClient { const hexTransaction = transaction.serialize().toString('hex'); + // Broadcast boundary: the RPC call has already been made at this point, so a rejection here is + // ambiguous (skipPreflight means the node may have accepted and relayed the tx before erroring). const result = await this.sendSignedTransaction(hexTransaction); - if (result.error) throw new Error(result.error.message); + if (result.error) throw new TxBroadcastError(result.error.message, { cause: result.error }); + // A JSON-RPC response with HTTP 200 can still omit the hash (falsy result.error) - fail-closed + // explicitly instead of relying on pendingPayout(undefined) to reject implicitly downstream. + if (!result.hash) throw new TxBroadcastError('Broadcast returned an empty tx hash', { cause: result }); return result.hash; } diff --git a/src/integration/blockchain/spark/__tests__/spark-client.spec.ts b/src/integration/blockchain/spark/__tests__/spark-client.spec.ts new file mode 100644 index 0000000000..798c858c07 --- /dev/null +++ b/src/integration/blockchain/spark/__tests__/spark-client.spec.ts @@ -0,0 +1,164 @@ +/** + * Focused unit test for the SparkClient broadcast boundary: sendTransaction syncs leaves (retryable, + * idempotent - routed through this.call()) and THEN calls wallet.transfer, which signs AND sends + * atomically inside the SDK. wallet.transfer must never be retried by this.call()'s reconnect-and-retry + * (which would resend an already-settled transfer): a failure at or after that call is wrapped into a + * TxBroadcastError instead. Wallet resolution / leaf-sync failures never reach wallet.transfer and stay + * plain errors (safe to retry through this.call()). + */ + +import { SparkWallet } from '@buildonspark/spark-sdk'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { SparkClient } from '../spark-client'; + +jest.mock('@buildonspark/spark-sdk', () => ({ + SparkWallet: { initialize: jest.fn() }, +})); + +jest.mock('src/config/config', () => ({ + GetConfig: () => ({ + blockchain: { + spark: { + sparkWalletSeed: 'test seed phrase', + }, + }, + }), +})); + +function emptyAsyncGenerator(): AsyncGenerator { + return (async function* () {})(); +} + +describe('SparkClient', () => { + let client: SparkClient; + let mockWallet: { + getSparkAddress: jest.Mock; + getBalance: jest.Mock; + transfer: jest.Mock; + getTransfer: jest.Mock; + optimizeLeaves: jest.Mock; + optimizeTokenOutputs: jest.Mock; + on: jest.Mock; + }; + + beforeEach(() => { + mockWallet = { + getSparkAddress: jest.fn().mockResolvedValue('spark1testwalletaddress'), + getBalance: jest.fn().mockResolvedValue({ balance: 50000n }), + transfer: jest.fn().mockResolvedValue({ id: 'tx-abc123' }), + getTransfer: jest.fn(), + optimizeLeaves: jest.fn().mockImplementation(() => emptyAsyncGenerator()), + optimizeTokenOutputs: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + }; + + // SparkWallet.initialize is a bare jest.fn() from the module factory mock (not a real method), so + // jest.restoreAllMocks() cannot reset its call history the way it does for a genuine jest.spyOn + // target - clear it explicitly every test to keep call-count assertions isolated per test. + (SparkWallet.initialize as jest.Mock).mockClear(); + jest.spyOn(SparkWallet, 'initialize').mockResolvedValue({ wallet: mockWallet as any } as any); + + client = new SparkClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendTransaction', () => { + it('should convert BTC amount to satoshis and return txid', async () => { + const result = await client.sendTransaction('spark1destination', 0.5); + + expect(mockWallet.transfer).toHaveBeenCalledWith({ + amountSats: 50_000_000, + receiverSparkAddress: 'spark1destination', + }); + expect(result).toEqual({ txid: 'tx-abc123', fee: 0 }); + }); + }); + + // --- SEND TRANSACTION - BROADCAST BOUNDARY --- // + + describe('sendTransaction - broadcast boundary', () => { + it('wraps a wallet.transfer failure into a TxBroadcastError, keeping message and cause', async () => { + const cause = new Error('unexpected rejection'); + mockWallet.transfer.mockRejectedValue(cause); + + let error: unknown; + try { + await client.sendTransaction('spark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('unexpected rejection'); + expect((error as TxBroadcastError).cause).toBe(cause); + }); + + it('wraps a non-Error rejection using String(e)', async () => { + mockWallet.transfer.mockRejectedValue('plain string rejection'); + + let error: unknown; + try { + await client.sendTransaction('spark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('plain string rejection'); + }); + + it('never resends when wallet.transfer fails with a channel-shutdown error (fail-closed, no double-send), but resets the cache so the next call reconnects', async () => { + mockWallet.transfer.mockRejectedValue(new Error('Channel has been shut down')); + + await expect(client.sendTransaction('spark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.transfer).toHaveBeenCalledTimes(1); // never resent within this call + + const freshWallet = { ...mockWallet, transfer: jest.fn().mockResolvedValue({ id: 'tx-fresh' }) }; + jest.spyOn(SparkWallet, 'initialize').mockResolvedValue({ wallet: freshWallet as any } as any); + + const result = await client.sendTransaction('spark1destination', 0.5); + + expect(result).toEqual({ txid: 'tx-fresh', fee: 0 }); // next call picked up the fresh wallet + expect(freshWallet.transfer).toHaveBeenCalledTimes(1); + }); + + it('does not reset the cache when wallet.transfer fails with an unrelated error', async () => { + mockWallet.transfer.mockRejectedValue(new Error('insufficient funds')); + + await expect(client.sendTransaction('spark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + + mockWallet.transfer.mockResolvedValue({ id: 'tx-retry' }); + await client.sendTransaction('spark1destination', 0.5); + + // still the same wallet instance from SparkWallet.initialize - no reinitialization happened + expect(SparkWallet.initialize).toHaveBeenCalledTimes(1); + }); + + it('propagates a leaf-sync (pre-broadcast) failure unchanged and never reaches wallet.transfer', async () => { + const syncError = new Error('optimizeLeaves failed'); + mockWallet.optimizeLeaves.mockImplementation(() => { + throw syncError; + }); + + let error: unknown; + try { + await client.sendTransaction('spark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.transfer).not.toHaveBeenCalled(); + }); + + it('wraps an empty transfer id into a TxBroadcastError (fail-closed silent failure)', async () => { + mockWallet.transfer.mockResolvedValue({ id: '' }); + + await expect(client.sendTransaction('spark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.transfer).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/integration/blockchain/spark/spark-client.ts b/src/integration/blockchain/spark/spark-client.ts index df4e07859a..cd06ccdff0 100644 --- a/src/integration/blockchain/spark/spark-client.ts +++ b/src/integration/blockchain/spark/spark-client.ts @@ -5,6 +5,7 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { AsyncField } from 'src/shared/utils/async-field'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient } from '../shared/util/blockchain-client'; export interface SparkTransaction { @@ -84,18 +85,41 @@ export class SparkClient extends BlockchainClient { // --- TRANSACTION METHODS --- // async sendTransaction(to: string, amount: number): Promise<{ txid: string; fee: number }> { - return this.call(async (wallet) => { - const amountSats = Math.round(amount * 1e8); + // Pre-broadcast: resolving the wallet and syncing leaves never calls wallet.transfer, so a + // failure here is provably pre-broadcast - routed through this.call() so a channel-shutdown + // error still gets its reconnect-and-retry (safe: transfer was never reached yet). + const wallet = await this.call(async (w) => { + await this.syncLeaves(w); + return w; + }); - await this.syncLeaves(wallet); + const amountSats = Math.round(amount * 1e8); + // Broadcast boundary: wallet.transfer signs AND sends atomically inside the SDK - there is no + // separate pre-broadcast step to isolate, so any failure from here on is ambiguous (the transfer + // may already be in-flight/settled on Spark's side). Deliberately NOT routed through + // this.call()'s reconnect-and-retry: that wrapper re-invokes the whole operation on a + // channel-shutdown error, which for a transfer would risk sending twice. Reset the cache on such + // an error so only the *next* call reconnects, without resending this one. + try { const result = await wallet.transfer({ amountSats, receiverSparkAddress: to, }); + // An empty transfer id from the SDK is an ambiguous silent failure - fail-closed rather than + // returning it and letting the empty id roll the order back for re-broadcast. + if (!result?.id) throw new TxBroadcastError('Spark broadcast returned an empty transfer id'); + return { txid: result.id, fee: 0 }; - }); + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + if (e?.message?.includes('Channel has been shut down')) { + this.wallet.reset(); + this.cachedAddress.reset(); + } + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async getTransaction(txId: string): Promise { diff --git a/src/integration/blockchain/tron/__tests__/tron-client.spec.ts b/src/integration/blockchain/tron/__tests__/tron-client.spec.ts new file mode 100644 index 0000000000..0493d1c19b --- /dev/null +++ b/src/integration/blockchain/tron/__tests__/tron-client.spec.ts @@ -0,0 +1,183 @@ +/** + * Focused unit test for the TronClient broadcast boundary: sendNativeCoin/sendToken forward a single + * gateway call (`POST /transaction` / `POST /trc20/transaction`) that signs AND broadcasts the tx + * atomically in one remote request - there is no separate local pre-broadcast step to isolate, so + * ANY failure from that POST call on is ambiguous (the tx may already be on-chain) and gets wrapped + * into a TxBroadcastError. sendToken's decimals check happens before the POST and stays a plain, + * provably pre-broadcast error. + * + * TronClient's constructor wires up a real TronWallet from a seed that needs live config. To isolate + * JUST the changed methods, we call the private prototype methods directly via Function.prototype.call + * against a minimal stub `this`, following the same pattern as cardano/solana/icp-client.spec.ts. + */ + +import { ConfigService } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { TronClient } from '../tron-client'; + +const proto = TronClient.prototype as any; + +function createClientStub(post: jest.Mock): any { + const client = Object.create(TronClient.prototype); + client.http = { post }; + return client; +} + +describe('TronClient - broadcast boundary', () => { + beforeAll(() => { + new ConfigService(); // sets module-level Config (tronApiUrl/tronApiKey read inside sendNativeCoin/sendToken) + }); + + describe('sendNativeCoin(...)', () => { + it('returns the txId when the gateway call succeeds', async () => { + const post = jest.fn().mockResolvedValue({ txId: 'TX_HASH_01' }); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + const txId = await proto.sendNativeCoin.call(client, wallet, 'ADDR_01', 5); + + expect(txId).toBe('TX_HASH_01'); + expect(post).toHaveBeenCalledTimes(1); + expect(post.mock.calls[0][1]).toEqual({ + fromPrivateKey: 'PRIVATE_KEY_HEX', + to: 'ADDR_01', + amount: '5', + }); + }); + + it('wraps a gateway rejection into a TxBroadcastError (fail-closed: sign+broadcast are atomic)', async () => { + const cause = new Error('gateway unreachable'); + const post = jest.fn().mockRejectedValue(cause); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'ADDR_01', 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('gateway unreachable'); + expect((error as TxBroadcastError).cause).toBe(cause); + }); + + it('wraps a non-Error rejection using String(e)', async () => { + const post = jest.fn().mockRejectedValue('plain string rejection'); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'ADDR_01', 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('plain string rejection'); + }); + + it('wraps a 200 response without a txId into a TxBroadcastError (fail-closed silent failure)', async () => { + const post = jest.fn().mockResolvedValue({}); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'ADDR_01', 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Tron broadcast returned an empty txId'); + }); + }); + + describe('sendToken(...)', () => { + const token = createCustomAsset({ decimals: 6, chainId: '0xTOKENADDR', uniqueName: 'Tron/USDT' }); + + it('throws a plain (non-TxBroadcastError) Error when the token has no decimals - pre-broadcast, never reaches the gateway', async () => { + const post = jest.fn(); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + const tokenWithoutDecimals = createCustomAsset({ decimals: undefined, uniqueName: 'Tron/BAD' }); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'ADDR_01', tokenWithoutDecimals, 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('No decimals found in token Tron/BAD'); + expect(post).not.toHaveBeenCalled(); + }); + + it('returns the txId when the gateway call succeeds', async () => { + const post = jest.fn().mockResolvedValue({ txId: 'TX_HASH_02' }); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + const txId = await proto.sendToken.call(client, wallet, 'ADDR_01', token, 5); + + expect(txId).toBe('TX_HASH_02'); + expect(post).toHaveBeenCalledTimes(1); + }); + + it('wraps a gateway rejection into a TxBroadcastError (fail-closed: sign+broadcast are atomic)', async () => { + const cause = new Error('gateway rejected the trc20 tx'); + const post = jest.fn().mockRejectedValue(cause); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'ADDR_01', token, 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('gateway rejected the trc20 tx'); + expect((error as TxBroadcastError).cause).toBe(cause); + }); + + it('wraps a non-Error rejection using String(e)', async () => { + const post = jest.fn().mockRejectedValue('plain string rejection'); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'ADDR_01', token, 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('plain string rejection'); + }); + + it('wraps a 200 response without a txId into a TxBroadcastError (fail-closed silent failure)', async () => { + const post = jest.fn().mockResolvedValue({}); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'ADDR_01', token, 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Tron broadcast returned an empty txId'); + }); + }); +}); diff --git a/src/integration/blockchain/tron/tron-client.ts b/src/integration/blockchain/tron/tron-client.ts index cf3ffb310c..9ef8333aa9 100644 --- a/src/integration/blockchain/tron/tron-client.ts +++ b/src/integration/blockchain/tron/tron-client.ts @@ -1,4 +1,5 @@ import { Config } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpRequestConfig, HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; @@ -195,8 +196,11 @@ export class TronClient extends BlockchainClient { private async sendNativeCoin(wallet: TronWallet, toAddress: string, amount: number): Promise { const url = Config.blockchain.tron.tronApiUrl; - return this.http - .post( + // Broadcast boundary: this gateway call signs AND broadcasts the tx atomically in a single + // remote call - there is no separate local pre-broadcast step to isolate, so any failure from + // here on is ambiguous (the tx may already be on-chain). + try { + const response = await this.http.post( `${url}/transaction`, { fromPrivateKey: wallet.privateKey, @@ -204,8 +208,17 @@ export class TronClient extends BlockchainClient { amount: amount.toString(), }, this.httpConfig(), - ) - .then((r) => r.txId); + ); + + // A 200 response without a txId is an in-band silent failure - treat it as ambiguous + // (fail-closed) rather than returning an empty id that would later roll back for re-broadcast. + if (!response?.txId) throw new TxBroadcastError('Tron broadcast returned an empty txId', { cause: response }); + + return response.txId; + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async sendTokenFromAccount(account: WalletAccount, toAddress: string, token: Asset, amount: number): Promise { @@ -218,12 +231,14 @@ export class TronClient extends BlockchainClient { } private async sendToken(wallet: TronWallet, toAddress: string, token: Asset, amount: number): Promise { + // Pre-broadcast: local validation only, no network call - stays a plain Error. if (!token.decimals) throw new Error(`No decimals found in token ${token.uniqueName}`); const url = Config.blockchain.tron.tronApiUrl; - return this.http - .post( + // Broadcast boundary: see sendNativeCoin. + try { + const response = await this.http.post( `${url}/trc20/transaction`, { fromPrivateKey: wallet.privateKey, @@ -233,8 +248,16 @@ export class TronClient extends BlockchainClient { feeLimit: TronUtil.fromSunAmount(Config.blockchain.tron.sendTokenFeeLimit), }, this.httpConfig(), - ) - .then((r) => r.txId); + ); + + // See sendNativeCoin: a 200 without a txId is an ambiguous in-band failure, not an empty success. + if (!response?.txId) throw new TxBroadcastError('Tron broadcast returned an empty txId', { cause: response }); + + return response.txId; + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async sendSignedTransaction(tx: string): Promise { diff --git a/src/integration/blockchain/zano/__test__/zano-client.spec.ts b/src/integration/blockchain/zano/__test__/zano-client.spec.ts new file mode 100644 index 0000000000..35169c70f3 --- /dev/null +++ b/src/integration/blockchain/zano/__test__/zano-client.spec.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for ZanoClient's broadcast boundary. + * + * The wallet RPC's 'transfer' method builds, signs and relays a Zano transaction atomically in + * one call - there is no separate pre-broadcast step to exclude (the pre-broadcast balance checks + * in sendCoins/sendTokens are separate 'getbalance' RPC calls). A failure of the HTTP call itself + * or a response missing tx_details are both ambiguous (the wallet may have already relayed before + * the response was lost/rejected) and must surface as TxBroadcastError, mirroring the Solana + * sendTransaction boundary (result.error / empty hash -> TxBroadcastError). + */ + +import { HttpService } from 'src/shared/services/http.service'; +import { PayoutGroup } from 'src/subdomains/supporting/payout/services/base/payout-bitcoin-based.service'; +import { TxBroadcastError } from '../../shared/errors/tx-broadcast.error'; +import { ZanoClient } from '../zano-client'; + +jest.mock('src/config/config', () => { + const mockConfig = { + blockchain: { + zano: { + node: { url: 'https://zano-node.test' }, + wallet: { url: 'https://zano-wallet.test', address: 'ZANO_WALLET_ADDR' }, + coinId: 'd6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a', + fee: 0.01, + }, + }, + }; + return { + Config: mockConfig, + GetConfig: () => mockConfig, + }; +}); + +describe('ZanoClient - broadcast boundary', () => { + let client: ZanoClient; + let mockPost: jest.Mock; + + beforeEach(() => { + mockPost = jest.fn().mockImplementation((_url, params) => { + if (params.method === 'getbalance') { + // 100 ZANO unlocked, comfortably above payout amount + fee + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + } + return Promise.resolve({ result: { tx_details: { tx_hash: 'TX_HASH_01' } } }); + }); + + const mockHttpService = { + post: mockPost, + get: jest.fn(), + } as unknown as jest.Mocked; + + client = new ZanoClient(mockHttpService); + }); + + const payout: PayoutGroup = [{ addressTo: 'ZANO_DEST_ADDR', amount: 1.5 }]; + + describe('sendCoins(...)', () => { + it('returns the tx id on a successful transfer', async () => { + const result = await client.sendCoins(payout); + + expect(result.txId).toBe('TX_HASH_01'); + }); + + it('wraps a failure of the underlying HTTP transfer call into a TxBroadcastError', async () => { + const httpError = new Error('socket hang up'); + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.reject(httpError); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('socket hang up'); + expect((error as TxBroadcastError).cause).toBe(httpError); + }); + + it('wraps a non-Error rejection of the transfer call into a TxBroadcastError via String(e)', async () => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.reject('gateway timeout'); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('gateway timeout'); + }); + + it('wraps a transfer response missing tx_details into a TxBroadcastError', async () => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.resolve({ result: {} }); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toContain('Transfer not sent'); + }); + + it('wraps a malformed null transfer body (throwing while reading response.result) into a TxBroadcastError, staying fail-closed', async () => { + // A null/undefined body would make createSendTransferResult throw a plain TypeError; since it + // now runs inside the boundary, that becomes a TxBroadcastError instead of a self-healing error. + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.resolve(null); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + }); + + it('does not wrap a pre-broadcast insufficient-balance failure (plain Error propagates unchanged)', async () => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 0, unlocked_balance: 0, balances: [] } }); + return Promise.resolve({ result: { tx_details: { tx_hash: 'TX_HASH_01' } } }); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toContain('Unlocked coin balance'); + }); + }); +}); diff --git a/src/integration/blockchain/zano/zano-client.ts b/src/integration/blockchain/zano/zano-client.ts index 5a138211f1..7d5717a55c 100644 --- a/src/integration/blockchain/zano/zano-client.ts +++ b/src/integration/blockchain/zano/zano-client.ts @@ -7,6 +7,7 @@ import { Util } from 'src/shared/utils/util'; import { PayoutGroup } from 'src/subdomains/supporting/payout/services/base/payout-bitcoin-based.service'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient, BlockchainToken } from '../shared/util/blockchain-client'; import { ZanoAddressDto, @@ -251,6 +252,11 @@ export class ZanoClient extends BlockchainClient { return this.doSendTransfer(payout, payoutAmount, token.decimals, token.chainId); } + // Broadcast boundary: the wallet RPC's 'transfer' method builds, signs and relays the tx + // atomically in one call - there is no separate pre-broadcast step. A failure of the HTTP call + // itself or a response missing tx_details are both ambiguous (the wallet may have already + // relayed before the response was lost/rejected), mirroring the Solana sendTransaction boundary + // (result.error / empty hash -> TxBroadcastError). private async doSendTransfer( payout: PayoutGroup, payoutAmount: number, @@ -271,15 +277,23 @@ export class ZanoClient extends BlockchainClient { service_entries: [], }); - return this.http - .post<{ + try { + const response = await this.http.post<{ result: { tx_details: { tx_hash: string } }; - }>(`${Config.blockchain.zano.wallet.url}/json_rpc`, transferParams) - .then((r) => this.createSendTransferResult(payoutAmount, r)); + }>(`${Config.blockchain.zano.wallet.url}/json_rpc`, transferParams); + + // Response mapping stays inside the boundary: a malformed/empty body throwing while reading + // response.result would otherwise be a plain error and self-heal a possibly-relayed transfer. + return this.createSendTransferResult(payoutAmount, response); + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } private createSendTransferResult(payoutAmount: number, response?: any): ZanoSendTransferResultDto { - if (!response.result?.tx_details) throw new Error(`Transfer not sent: response was ${JSON.stringify(response)}`); + if (!response.result?.tx_details) + throw new TxBroadcastError(`Transfer not sent: response was ${JSON.stringify(response)}`); return { txId: response.result.tx_details.tx_hash, diff --git a/src/integration/lightning/__tests__/lightning-client.spec.ts b/src/integration/lightning/__tests__/lightning-client.spec.ts new file mode 100644 index 0000000000..67a8fb2b38 --- /dev/null +++ b/src/integration/lightning/__tests__/lightning-client.spec.ts @@ -0,0 +1,137 @@ +/** + * Focused unit test for the LightningClient broadcast boundary: the try/catch tightly wrapping the + * actual LND send-payment POST (sendPaymentByInvoice / sendPaymentByPublicKey), which translates a + * transport/timeout failure into a TxBroadcastError - the request may already have reached LND + * before the connection dropped. An in-band payment failure (LND responds 200 with payment_error + * set) is deliberately NOT this layer's concern: it resolves normally here and is turned into a + * plain, self-healing error one layer up, in LightningService#sendTransfer (see + * lightning.service.ts), since LND has then provably NOT routed the payment. + */ + +import { mock } from 'jest-mock-extended'; +import { ConfigService } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { HttpService } from 'src/shared/services/http.service'; +import { LightningClient } from '../lightning-client'; + +describe('LightningClient - broadcast boundary', () => { + let http: HttpService; + let client: LightningClient; + let postSpy: jest.SpyInstance; + + beforeAll(() => { + new ConfigService(); // sets module-level Config (LND api URL + TLS agent construction) + }); + + beforeEach(() => { + http = mock(); + postSpy = jest.spyOn(http, 'post'); + client = new LightningClient(http); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendPaymentByInvoice(...)', () => { + it('returns the LND response on a successful POST', async () => { + const response = { payment_hash: 'aGFzaA==', payment_error: '' }; + postSpy.mockResolvedValue(response); + + const result = await client.sendPaymentByInvoice('lnbc1invoice'); + + expect(result).toBe(response); + }); + + it('does NOT wrap an in-band payment_error response - it resolves normally, letting the caller (LightningService#sendTransfer) throw a plain, self-healing error', async () => { + const response = { payment_hash: '', payment_error: 'FAILURE_REASON_NO_ROUTE' }; + postSpy.mockResolvedValue(response); + + const result = await client.sendPaymentByInvoice('lnbc1invoice'); + + expect(result).toBe(response); // no throw here - in-band failure is not this layer's concern + }); + + it('wraps an Error thrown by the HTTP POST into a TxBroadcastError, preserving message and cause (fail-closed: request already sent)', async () => { + const transportError = new Error('socket hang up'); + postSpy.mockRejectedValue(transportError); + + let error: unknown; + try { + await client.sendPaymentByInvoice('lnbc1invoice'); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('socket hang up'); + expect((error as TxBroadcastError).cause).toBe(transportError); + }); + + it('wraps a non-Error rejection from the HTTP POST into a TxBroadcastError via String(e)', async () => { + postSpy.mockRejectedValue('ETIMEDOUT'); + + let error: unknown; + try { + await client.sendPaymentByInvoice('lnbc1invoice'); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('ETIMEDOUT'); + expect((error as TxBroadcastError).cause).toBe('ETIMEDOUT'); + }); + }); + + describe('sendPaymentByPublicKey(...)', () => { + it('returns the LND response on a successful POST', async () => { + const response = { payment_hash: 'aGFzaA==', payment_error: '' }; + postSpy.mockResolvedValue(response); + + const result = await client.sendPaymentByPublicKey('02aabbccddeeff', 0.001); + + expect(result).toBe(response); + }); + + it('does NOT wrap an in-band payment_error response - it resolves normally, letting the caller (LightningService#sendTransfer) throw a plain, self-healing error', async () => { + const response = { payment_hash: '', payment_error: 'FAILURE_REASON_NO_ROUTE' }; + postSpy.mockResolvedValue(response); + + const result = await client.sendPaymentByPublicKey('02aabbccddeeff', 0.001); + + expect(result).toBe(response); + }); + + it('wraps an Error thrown by the HTTP POST into a TxBroadcastError, preserving message and cause (fail-closed: request already sent)', async () => { + const transportError = new Error('ECONNRESET'); + postSpy.mockRejectedValue(transportError); + + let error: unknown; + try { + await client.sendPaymentByPublicKey('02aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('ECONNRESET'); + expect((error as TxBroadcastError).cause).toBe(transportError); + }); + + it('wraps a non-Error rejection from the HTTP POST into a TxBroadcastError via String(e)', async () => { + postSpy.mockRejectedValue('ETIMEDOUT'); + + let error: unknown; + try { + await client.sendPaymentByPublicKey('02aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('ETIMEDOUT'); + expect((error as TxBroadcastError).cause).toBe('ETIMEDOUT'); + }); + }); +}); diff --git a/src/integration/lightning/lightning-client.ts b/src/integration/lightning/lightning-client.ts index 359fedb0d6..d2f12682f6 100644 --- a/src/integration/lightning/lightning-client.ts +++ b/src/integration/lightning/lightning-client.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { Agent } from 'https'; import { Config } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { HttpError, HttpRequestConfig, HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; import { LnBitsWalletDto, LnBitsWalletPaymentDto, LnBitsWalletPaymentParamsDto } from './dto/lnbits.dto'; @@ -147,28 +148,42 @@ export class LightningClient implements CoinOnly { } async sendPaymentByInvoice(invoice: string): Promise { - return this.http.post( - `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, - { payment_request: invoice }, - this.httpLndConfig(), - ); + // Broadcast boundary: once this POST is sent, a transport/timeout failure is ambiguous - LND may + // have already dispatched the payment before the response reached us. A response.payment_error + // (checked by the caller, LightningService#sendTransfer) is the in-band, provably-not-routed + // case and is deliberately NOT covered here - it surfaces as a plain resolved value, not a + // rejection, so it never reaches this catch. + try { + return await this.http.post( + `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, + { payment_request: invoice }, + this.httpLndConfig(), + ); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async sendPaymentByPublicKey(publicKey: string, amount: number): Promise { const preImage = randomBytes(32); const paymentHash = Util.createHash(preImage, 'sha256', 'base64'); - return this.http.post( - `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, - { - dest: Buffer.from(publicKey, 'hex').toString('base64'), - amt: Math.round(LightningHelper.btcToSat(amount)), - final_cltv_delta: 0, - payment_hash: paymentHash, - dest_custom_records: { 5482373484: preImage.toString('base64') }, - }, - this.httpLndConfig(), - ); + // Broadcast boundary: see sendPaymentByInvoice. + try { + return await this.http.post( + `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, + { + dest: Buffer.from(publicKey, 'hex').toString('base64'), + amt: Math.round(LightningHelper.btcToSat(amount)), + final_cltv_delta: 0, + payment_hash: paymentHash, + dest_custom_records: { 5482373484: preImage.toString('base64') }, + }, + this.httpLndConfig(), + ); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } // --- LnBits --- // diff --git a/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts b/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts index 7d76a8ad9f..c72523e9fc 100644 --- a/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts +++ b/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts @@ -1,4 +1,7 @@ -import { PayoutOrderStatus } from '../payout-order.entity'; +import { getMetadataArgsStorage } from 'typeorm'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../payout-order.entity'; import { createCustomPayoutOrder } from '../__mocks__/payout-order.entity.mock'; describe('PayoutOrder', () => { @@ -108,6 +111,25 @@ describe('PayoutOrder', () => { expect(entity.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); }); + + it('returns this on a valid payoutTxId', () => { + const entity = createCustomPayoutOrder({ payoutTxId: undefined, status: undefined }); + + const result = entity.pendingPayout('PID_01'); + + expect(result).toBe(entity); + }); + + it('throws and leaves the order untouched when no payoutTxId is provided', () => { + const entity = createCustomPayoutOrder({ + payoutTxId: undefined, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + }); + + expect(() => entity.pendingPayout('')).toThrowError('No payoutTxId provided to PayoutOrder #pendingPayout(...)'); + expect(entity.payoutTxId).toBeUndefined(); + expect(entity.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); }); describe('#rollbackPayout(...)', () => { @@ -140,4 +162,130 @@ describe('PayoutOrder', () => { expect(entity.status).toBe(PayoutOrderStatus.COMPLETE); }); }); + + describe('#recordPreparationFee(...)', () => { + it('stores the preparation fee asset, amount and CHF amount and returns this', () => { + const entity = createCustomPayoutOrder({}); + const feeAsset = createCustomAsset({ name: 'BTC' }); + + const result = entity.recordPreparationFee(feeAsset, 0.0001, 4.2); + + expect(result).toBe(entity); + expect(entity.preparationFeeAsset).toBe(feeAsset); + expect(entity.preparationFeeAmount).toBe(0.0001); + expect(entity.preparationFeeAmountChf).toBe(4.2); + }); + }); + + describe('#recordPayoutFee(...)', () => { + it('stores the payout fee asset, amount and CHF amount and returns this', () => { + const entity = createCustomPayoutOrder({}); + const feeAsset = createCustomAsset({ name: 'BTC' }); + + const result = entity.recordPayoutFee(feeAsset, 0.0002, 8.4); + + expect(result).toBe(entity); + expect(entity.payoutFeeAsset).toBe(feeAsset); + expect(entity.payoutFeeAmount).toBe(0.0002); + expect(entity.payoutFeeAmountChf).toBe(8.4); + }); + }); + + describe('#recordPayoutFailure(...)', () => { + it('increments retryCount, truncates the error to 2048 chars, sets lastAttemptDate and returns this', () => { + const entity = createCustomPayoutOrder({ retryCount: 2 }); + const longMessage = 'X'.repeat(5000); + + const result = entity.recordPayoutFailure(longMessage); + + expect(result).toBe(entity); + expect(entity.retryCount).toBe(3); + expect(entity.lastError).toBe('X'.repeat(2048)); + expect(entity.lastError?.length).toBe(2048); + expect(entity.lastAttemptDate).toBeInstanceOf(Date); + }); + + it('starts retryCount at 1 when it was previously undefined', () => { + const entity = createCustomPayoutOrder({ retryCount: undefined }); + + entity.recordPayoutFailure('boom'); + + expect(entity.retryCount).toBe(1); + expect(entity.lastError).toBe('boom'); + }); + + it('leaves lastError undefined when no message is provided', () => { + const entity = createCustomPayoutOrder({ retryCount: 0 }); + + entity.recordPayoutFailure(undefined as unknown as string); + + expect(entity.retryCount).toBe(1); + expect(entity.lastError).toBeUndefined(); + expect(entity.lastAttemptDate).toBeInstanceOf(Date); + }); + }); + + describe('#resetPayoutRetry(...)', () => { + it('clears retryCount, lastError and lastAttemptDate and returns this', () => { + const entity = createCustomPayoutOrder({ retryCount: 5 }); + entity.lastError = 'previous error'; + entity.lastAttemptDate = new Date(); + + const result = entity.resetPayoutRetry(); + + expect(result).toBe(entity); + expect(entity.retryCount).toBe(0); + expect(entity.lastError).toBeNull(); + expect(entity.lastAttemptDate).toBeNull(); + }); + }); + + describe('#payoutFee', () => { + it('returns the payout fee asset and the rounded sum of payout and preparation amounts', () => { + const feeAsset = createCustomAsset({ name: 'BTC' }); + const entity = createCustomPayoutOrder({}); + entity.payoutFeeAsset = feeAsset; + entity.payoutFeeAmount = 0.00000002; + entity.preparationFeeAmount = 0.00000001; + + expect(entity.payoutFee).toEqual({ asset: feeAsset, amount: 0.00000003 }); + }); + }); + + describe('#feeAmountChf', () => { + it('sums the preparation and payout CHF fee amounts', () => { + const entity = createCustomPayoutOrder({}); + entity.preparationFeeAmountChf = 1.5; + entity.payoutFeeAmountChf = 2.25; + + expect(entity.feeAmountChf).toBe(3.75); + }); + }); + + describe('ORM metadata', () => { + it('maps asset, preparationFeeAsset and payoutFeeAsset as ManyToOne relations to Asset', () => { + const relations = getMetadataArgsStorage().relations.filter((r) => r.target === PayoutOrder); + const assetRelation = relations.find((r) => r.propertyName === 'asset'); + const preparationFeeRelation = relations.find((r) => r.propertyName === 'preparationFeeAsset'); + const payoutFeeRelation = relations.find((r) => r.propertyName === 'payoutFeeAsset'); + + expect((assetRelation!.type as () => unknown)()).toBe(Asset); + expect((preparationFeeRelation!.type as () => unknown)()).toBe(Asset); + expect((payoutFeeRelation!.type as () => unknown)()).toBe(Asset); + }); + + it('declares a unique composite index on (context, correlationId)', () => { + const index = getMetadataArgsStorage().indices.find( + (i) => i.target === PayoutOrder && typeof i.columns === 'function', + ); + + expect(index).toBeDefined(); + expect(index!.unique).toBe(true); + + const order = createCustomPayoutOrder({ context: PayoutOrderContext.BUY_CRYPTO, correlationId: 'CID_XYZ' }); + const columns = (index!.columns as (p: PayoutOrder) => unknown[])(order); + + expect(columns).toEqual([PayoutOrderContext.BUY_CRYPTO, 'CID_XYZ']); + }); + }); }); diff --git a/src/subdomains/supporting/payout/exceptions/__tests__/payout-broadcast.exception.spec.ts b/src/subdomains/supporting/payout/exceptions/__tests__/payout-broadcast.exception.spec.ts new file mode 100644 index 0000000000..0f376b1b9d --- /dev/null +++ b/src/subdomains/supporting/payout/exceptions/__tests__/payout-broadcast.exception.spec.ts @@ -0,0 +1,20 @@ +import { PayoutBroadcastException } from '../payout-broadcast.exception'; + +describe('PayoutBroadcastException', () => { + it('sets message and name', () => { + const exception = new PayoutBroadcastException('broadcast may be in-flight'); + + expect(exception.message).toBe('broadcast may be in-flight'); + expect(exception.name).toBe('PayoutBroadcastException'); + expect(exception).toBeInstanceOf(Error); + expect(exception.cause).toBeUndefined(); + }); + + it('carries the cause through to the Error options', () => { + const cause = new Error('underlying RPC error'); + + const exception = new PayoutBroadcastException('broadcast may be in-flight', { cause }); + + expect(exception.cause).toBe(cause); + }); +}); diff --git a/src/subdomains/supporting/payout/exceptions/payout-broadcast.exception.ts b/src/subdomains/supporting/payout/exceptions/payout-broadcast.exception.ts new file mode 100644 index 0000000000..1dca1989d9 --- /dev/null +++ b/src/subdomains/supporting/payout/exceptions/payout-broadcast.exception.ts @@ -0,0 +1,9 @@ +// Marks a payout failure at or after the actual on-chain broadcast call, i.e. the transaction may +// already be in-flight. Distinguishes this ambiguous case from a provable pre-broadcast failure +// (gas estimation, nonce fetch, gasPrice RPC) so the caller can decide fail-closed vs. auto-retry. +export class PayoutBroadcastException extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'PayoutBroadcastException'; + } +} diff --git a/src/subdomains/supporting/payout/repositories/__tests__/payout-order.repository.spec.ts b/src/subdomains/supporting/payout/repositories/__tests__/payout-order.repository.spec.ts new file mode 100644 index 0000000000..66ca7606cd --- /dev/null +++ b/src/subdomains/supporting/payout/repositories/__tests__/payout-order.repository.spec.ts @@ -0,0 +1,9 @@ +import { PayoutOrderRepository } from '../payout-order.repository'; + +describe('PayoutOrderRepository', () => { + it('constructs against the provided entity manager', () => { + const repo = new PayoutOrderRepository({} as any); + + expect(repo).toBeInstanceOf(PayoutOrderRepository); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-arkade.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-arkade.service.spec.ts new file mode 100644 index 0000000000..cc3451151c --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-arkade.service.spec.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutArkadeService: sendTransaction catches a + * TxBroadcastError from the (shared, non-payout-specific) ArkadeService/ArkadeClient and re-throws it + * as a PayoutBroadcastException, so the payout strategy can tell "wallet.sendBitcoin was reached" + * apart from a provable pre-broadcast failure (see ArkadeStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { ArkadeClient } from 'src/integration/blockchain/arkade/arkade-client'; +import { ArkadeService } from 'src/integration/blockchain/arkade/arkade.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutArkadeService } from '../payout-arkade.service'; + +describe('PayoutArkadeService', () => { + let arkadeService: ArkadeService; + let arkadeClient: ArkadeClient; + let service: PayoutArkadeService; + let sendTransactionSpy: jest.SpyInstance; + + beforeEach(() => { + arkadeService = mock(); + arkadeClient = mock(); + jest.spyOn(arkadeService, 'getDefaultClient').mockReturnValue(arkadeClient); + sendTransactionSpy = jest.spyOn(arkadeService, 'sendTransaction'); + + service = new PayoutArkadeService(arkadeService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendTransaction(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('sendBitcoin failed'); + sendTransactionSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendTransaction('ARK_ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('sendBitcoin failed'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTransactionSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendTransaction('ARK_ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the txid on success and forwards address/amount', async () => { + sendTransactionSpy.mockResolvedValue({ txid: 'TX_HASH_01', fee: 0 }); + + await expect(service.sendTransaction('ARK_ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendTransactionSpy).toHaveBeenCalledWith('ARK_ADDR_01', 1.5); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the shared service', async () => { + const isHealthySpy = jest.spyOn(arkadeService, 'isHealthy').mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(arkadeClient, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(arkadeService, 'getTxActualFee').mockResolvedValue(0.0001); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.0001]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + jest.spyOn(arkadeClient, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(arkadeService, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentFeeForTransaction(...)', () => { + it('returns the native fee for a coin asset', async () => { + const coin = createCustomAsset({ type: AssetType.COIN }); + const getNativeFeeSpy = jest.spyOn(arkadeService, 'getNativeFee').mockResolvedValue(0.00001); + + await expect(service.getCurrentFeeForTransaction(coin)).resolves.toBe(0.00001); + expect(getNativeFeeSpy).toHaveBeenCalledTimes(1); + }); + + it('throws for a non-coin asset without touching the native fee', () => { + const token = createDefaultAsset(); // AssetType.TOKEN + const getNativeFeeSpy = jest.spyOn(arkadeService, 'getNativeFee'); + + expect(() => service.getCurrentFeeForTransaction(token)).toThrow('Method not implemented'); + expect(getNativeFeeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin-testnet4.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin-testnet4.service.spec.ts new file mode 100644 index 0000000000..61982ff7fa --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin-testnet4.service.spec.ts @@ -0,0 +1,192 @@ +/** + * Unit Tests for PayoutBitcoinTestnet4Service + * + * Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + * BitcoinTestnet4Client means the on-chain send was reached (tx may be in-flight) and must + * surface as PayoutBroadcastException so BitcoinBasedStrategy#send keeps the order + * PAYOUT_DESIGNATED (fail-closed) instead of rolling back and risking a double-spend on retry. + */ + +import { Config, ConfigService } from 'src/config/config'; +import { BitcoinTestnet4Client } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4-client'; +import { BitcoinTestnet4Service } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutGroup } from '../base/payout-bitcoin-based.service'; +import { PayoutBitcoinTestnet4Service } from '../payout-bitcoin-testnet4.service'; + +describe('PayoutBitcoinTestnet4Service', () => { + let service: PayoutBitcoinTestnet4Service; + let mockClient: jest.Mocked; + let sendManySpy: jest.Mock; + + beforeAll(() => { + // PayoutBitcoinTestnet4Service#getCurrentFeeRate reads the module-level `Config` singleton + // directly (only populated by the real app bootstrap), so a plain unit test must set it. + new ConfigService(); + }); + + beforeEach(() => { + sendManySpy = jest.fn().mockResolvedValue('TX_HASH_01'); + + mockClient = { + sendMany: sendManySpy, + getInfo: jest.fn(), + getTx: jest.fn(), + estimateSmartFee: jest.fn().mockResolvedValue(5), + } as unknown as jest.Mocked; + + const mockBitcoinTestnet4Service = { + getDefaultClient: jest.fn().mockReturnValue(mockClient), + } as unknown as jest.Mocked; + + service = new PayoutBitcoinTestnet4Service(mockBitcoinTestnet4Service); + }); + + describe('sendUtxoToMany()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + + const result = await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + + expect(result).toBe('TX_HASH_01'); + }); + + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const cause = new TxBroadcastError('Bitcoin RPC send failed: timeout'); + sendManySpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Bitcoin RPC send failed: timeout'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const plainError = new Error('unrelated client error'); + sendManySpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('isHealthy()', () => { + it('returns true when the node reports info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce({ blocks: 1000 }); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(mockClient.getInfo).toHaveBeenCalledTimes(1); + }); + + it('returns false when the node reports no info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce(null); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + + it('returns false (never throws) when the info call rejects', async () => { + (mockClient.getInfo as jest.Mock).mockRejectedValueOnce(new Error('node unreachable')); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + }); + + describe('getPayoutCompletionData()', () => { + it('returns [true, negated fee] once the tx is found', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + confirmations: 3, + time: 0, + amount: 0.5, + fee: -0.0002, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.0002]); + expect(mockClient.getTx).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('defaults the fee to 0 for a found tx that carries no fee field', async () => { + mockClient.getTx.mockResolvedValueOnce({ txid: 'TX_HASH_01', confirmations: 3, time: 0, amount: 0.5 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBe(true); + expect(result[1]).toBe(-0); // -(undefined ?? 0) + }); + + it('returns [false, 0] while the tx is not yet in the wallet', async () => { + mockClient.getTx.mockResolvedValueOnce(null); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([false, 0]); + }); + }); + + describe('getCurrentFeeRate()', () => { + it('applies the min-tx-amount multiplier (1.5) to the estimated smart fee', async () => { + mockClient.estimateSmartFee.mockResolvedValueOnce(5); + + await expect(service.getCurrentFeeRate()).resolves.toBe(7.5); + expect(mockClient.estimateSmartFee).toHaveBeenCalledWith(1); + }); + + it('falls back to the minimum rate of 1 when the smart-fee estimate is null', async () => { + mockClient.estimateSmartFee.mockResolvedValueOnce(null); + + await expect(service.getCurrentFeeRate()).resolves.toBe(1.5); + }); + + it('applies no multiplier when the min-tx-amount guard is disabled', async () => { + const originalMinTxAmount = Config.blockchain.bitcoinTestnet4.minTxAmount; + Config.blockchain.bitcoinTestnet4.minTxAmount = 0; + + try { + mockClient.estimateSmartFee.mockResolvedValueOnce(5); + + await expect(service.getCurrentFeeRate()).resolves.toBe(5); + } finally { + Config.blockchain.bitcoinTestnet4.minTxAmount = originalMinTxAmount; + } + }); + }); + + describe('with an unavailable default client', () => { + let serviceWithoutClient: PayoutBitcoinTestnet4Service; + + beforeEach(() => { + const mockServiceWithoutClient = { + getDefaultClient: jest.fn().mockReturnValue(undefined), + } as unknown as jest.Mocked; + + serviceWithoutClient = new PayoutBitcoinTestnet4Service(mockServiceWithoutClient); + }); + + it('isHealthy() returns false instead of throwing on the optional client', async () => { + await expect(serviceWithoutClient.isHealthy()).resolves.toBe(false); + }); + + it('getCurrentFeeRate() falls back to the minimum rate when the client is missing', async () => { + await expect(serviceWithoutClient.getCurrentFeeRate()).resolves.toBe(1.5); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin.service.spec.ts index d39fab7137..fa4fa4515e 100644 --- a/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin.service.spec.ts +++ b/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin.service.spec.ts @@ -9,8 +9,10 @@ import { BitcoinClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; import { BitcoinService } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { PayoutOrderContext } from '../../entities/payout-order.entity'; import { InvalidPayoutAmountException } from '../../exceptions/invalid-payout-amount.exception'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; import { PayoutGroup } from '../base/payout-bitcoin-based.service'; import { PayoutBitcoinService } from '../payout-bitcoin.service'; @@ -134,5 +136,127 @@ describe('PayoutBitcoinService', () => { expect(result).toBe('TX_HASH_01'); }); + + // Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + // client means the on-chain send was reached (tx may be in-flight) and must surface as + // PayoutBroadcastException so BitcoinBasedStrategy#send keeps the order PAYOUT_DESIGNATED + // (fail-closed) instead of rolling back and risking a double-spend on retry. + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + mockFeeService.getSendFeeRate.mockResolvedValueOnce(5); + const cause = new TxBroadcastError('Bitcoin RPC send failed: timeout'); + sendManySpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Bitcoin RPC send failed: timeout'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + mockFeeService.getSendFeeRate.mockResolvedValueOnce(5); + const plainError = new Error('unrelated client error'); + sendManySpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('should propagate a pre-broadcast fee-rate timeout unchanged and never reach sendMany', async () => { + // Fee estimation happens before sendMany is even called - a timeout here is provably + // pre-broadcast and must self-heal (rollback), not fail-closed like a broadcast timeout. + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const timeoutError = new Error('RPC timeout during fee estimation'); + mockFeeService.getSendFeeRate.mockRejectedValueOnce(timeoutError); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(timeoutError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + expect(sendManySpy).not.toHaveBeenCalled(); + }); + }); + + describe('isHealthy()', () => { + it('returns true when the node reports info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce({ blocks: 800000 }); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(mockClient.getInfo).toHaveBeenCalledTimes(1); + }); + + it('returns false when the node reports no info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce(null); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + + it('returns false (never throws) when the info call rejects', async () => { + (mockClient.getInfo as jest.Mock).mockRejectedValueOnce(new Error('node unreachable')); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + }); + + describe('getPayoutCompletionData()', () => { + it('returns [true, negated fee] once the tx is found (Bitcoin Core reports outgoing fees as negative)', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + confirmations: 3, + time: 0, + amount: 0.5, + fee: -0.0002, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.0002]); + expect(mockClient.getTx).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('defaults the fee to 0 for a found tx that carries no fee field', async () => { + mockClient.getTx.mockResolvedValueOnce({ txid: 'TX_HASH_01', confirmations: 3, time: 0, amount: 0.5 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBe(true); + expect(result[1]).toBe(-0); // -(undefined ?? 0) + }); + + it('returns [false, 0] while the tx is not yet in the wallet', async () => { + mockClient.getTx.mockResolvedValueOnce(null); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([false, 0]); + }); + }); + + describe('getCurrentFeeRate()', () => { + it('delegates to the fee service', async () => { + mockFeeService.getSendFeeRate.mockResolvedValueOnce(7); + + await expect(service.getCurrentFeeRate()).resolves.toBe(7); + expect(mockFeeService.getSendFeeRate).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-cardano.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-cardano.service.spec.ts new file mode 100644 index 0000000000..7613fd16bd --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-cardano.service.spec.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutCardanoService: sendNativeCoin/sendToken + * catch a TxBroadcastError from the (shared, non-payout-specific) CardanoService/CardanoClient and + * re-throw it as a PayoutBroadcastException, so the payout strategy can tell "the on-chain submit + * call was reached" apart from a provable pre-broadcast failure (see CardanoStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { CardanoService } from 'src/integration/blockchain/cardano/services/cardano.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutCardanoService } from '../payout-cardano.service'; + +describe('PayoutCardanoService', () => { + let cardanoService: CardanoService; + let service: PayoutCardanoService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + cardanoService = mock(); + sendNativeCoinFromDexSpy = jest.spyOn(cardanoService, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(cardanoService, 'sendTokenFromDex'); + + service = new PayoutCardanoService(cardanoService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('BlockFrost rejected the tx'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('BlockFrost rejected the tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('BlockFrost rejected the token tx'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('BlockFrost rejected the token tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] and reads the actual fee once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(cardanoService, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(cardanoService, 'getTxActualFee').mockResolvedValue(0.17); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.17]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + const isTxCompleteSpy = jest.spyOn(cardanoService, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(cardanoService, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentGasForCoinTransaction()', () => { + it('delegates to the shared service', async () => { + const gasSpy = jest.spyOn(cardanoService, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(1.23); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(1.23); + expect(gasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + const asset = createDefaultAsset(); + + it('delegates to the shared service, forwarding the token', async () => { + const gasSpy = jest.spyOn(cardanoService, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(2.34); + + await expect(service.getCurrentGasForTokenTransaction(asset)).resolves.toBe(2.34); + expect(gasSpy).toHaveBeenCalledWith(asset); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-evm-chains.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-evm-chains.service.spec.ts new file mode 100644 index 0000000000..f01a10061a --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-evm-chains.service.spec.ts @@ -0,0 +1,168 @@ +/** + * Each PayoutXxxService is a thin, chain-specific binding over the shared PayoutEvmService: its + * constructor wires the chain's EvmService#getDefaultClient() into the inherited send/gas/nonce/expiry + * logic (already fully covered behaviourally by payout-evm.service.spec.ts). PayoutBaseService, + * PayoutGnosisService and PayoutOptimismService additionally keep their own concretely-typed client + * reference and override the two gas getters - this suite covers exactly those two extra branches. + * + * Two shared, table-driven suites (real per-chain call sites, not a silent loop - mirrors the + * runDesignateBeforeBroadcastSuite pattern in payout-designate-before-broadcast.strategy.spec.ts) + * cover all nine chain services: the six "plain" ones that only inherit PayoutEvmService's gas + * getters, and the three "override" ones that shadow them with their own client. + */ + +import { mock } from 'jest-mock-extended'; +import { ArbitrumService } from 'src/integration/blockchain/arbitrum/arbitrum.service'; +import { BaseClient } from 'src/integration/blockchain/base/base-client'; +import { BaseService } from 'src/integration/blockchain/base/base.service'; +import { BscService } from 'src/integration/blockchain/bsc/bsc.service'; +import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; +import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; +import { GnosisClient } from 'src/integration/blockchain/gnosis/gnosis-client'; +import { GnosisService } from 'src/integration/blockchain/gnosis/gnosis.service'; +import { OptimismClient } from 'src/integration/blockchain/optimism/optimism-client'; +import { OptimismService } from 'src/integration/blockchain/optimism/optimism.service'; +import { PolygonService } from 'src/integration/blockchain/polygon/polygon.service'; +import { SepoliaService } from 'src/integration/blockchain/sepolia/sepolia.service'; +import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutArbitrumService } from '../payout-arbitrum.service'; +import { PayoutBaseService } from '../payout-base.service'; +import { PayoutBscService } from '../payout-bsc.service'; +import { PayoutCitreaService } from '../payout-citrea.service'; +import { PayoutEthereumService } from '../payout-ethereum.service'; +import { PayoutEvmService } from '../payout-evm.service'; +import { PayoutGnosisService } from '../payout-gnosis.service'; +import { PayoutOptimismService } from '../payout-optimism.service'; +import { PayoutPolygonService } from '../payout-polygon.service'; +import { PayoutSepoliaService } from '../payout-sepolia.service'; + +interface PlainEvmChainVariant { + name: string; + createUnderlying: () => any; + createPayoutService: (underlying: any) => PayoutEvmService; +} + +function runPlainEvmChainServiceSuite(variant: PlainEvmChainVariant): void { + describe(variant.name, () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('wires the chain EvmService default client into the constructor, feeding the inherited gas getter', async () => { + const underlying = variant.createUnderlying(); + const client = mock(); + jest.spyOn(underlying, 'getDefaultClient').mockReturnValue(client as any); + jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(999); + + const service = variant.createPayoutService(underlying); + + expect(underlying.getDefaultClient).toHaveBeenCalledTimes(1); + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(999); + expect(client.getCurrentGasCostForCoinTransaction).toHaveBeenCalledTimes(1); + }); + }); +} + +runPlainEvmChainServiceSuite({ + name: 'PayoutArbitrumService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutArbitrumService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutBscService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutBscService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutCitreaService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutCitreaService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutEthereumService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutEthereumService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutPolygonService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutPolygonService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutSepoliaService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutSepoliaService(underlying), +}); + +interface OverrideEvmChainVariant { + name: string; + createUnderlying: () => any; + createClient: () => any; + createPayoutService: (underlying: any) => PayoutBaseService | PayoutGnosisService | PayoutOptimismService; +} + +function runOverrideEvmChainServiceSuite(variant: OverrideEvmChainVariant): void { + describe(variant.name, () => { + let underlying: any; + let client: any; + let service: PayoutBaseService | PayoutGnosisService | PayoutOptimismService; + + beforeEach(() => { + underlying = variant.createUnderlying(); + client = variant.createClient(); + jest.spyOn(underlying, 'getDefaultClient').mockReturnValue(client); + + service = variant.createPayoutService(underlying); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('wires the chain EvmService default client into the constructor', () => { + expect(underlying.getDefaultClient).toHaveBeenCalled(); + }); + + it('getCurrentGasForCoinTransaction(...) delegates to the client', async () => { + jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(333); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(333); + expect(client.getCurrentGasCostForCoinTransaction).toHaveBeenCalledTimes(1); + }); + + it('getCurrentGasForTokenTransaction(...) delegates to the client, forwarding the token', async () => { + const token = createDefaultAsset(); + jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(444); + + await expect(service.getCurrentGasForTokenTransaction(token)).resolves.toBe(444); + expect(client.getCurrentGasCostForTokenTransaction).toHaveBeenCalledWith(token); + }); + }); +} + +runOverrideEvmChainServiceSuite({ + name: 'PayoutBaseService', + createUnderlying: () => mock(), + createClient: () => mock(), + createPayoutService: (underlying) => new PayoutBaseService(underlying), +}); + +runOverrideEvmChainServiceSuite({ + name: 'PayoutGnosisService', + createUnderlying: () => mock(), + createClient: () => mock(), + createPayoutService: (underlying) => new PayoutGnosisService(underlying), +}); + +runOverrideEvmChainServiceSuite({ + name: 'PayoutOptimismService', + createUnderlying: () => mock(), + createClient: () => mock(), + createPayoutService: (underlying) => new PayoutOptimismService(underlying), +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-evm.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-evm.service.spec.ts new file mode 100644 index 0000000000..416ecc2a9a --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-evm.service.spec.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutEvmService: sendNativeCoin/sendToken + * catch a TxBroadcastError from the (shared, non-payout-specific) EvmClient and re-throw it as a + * PayoutBroadcastException, so the payout strategy can tell "the on-chain send call was reached" + * apart from a provable pre-broadcast failure (see EvmStrategy#doPayout). Anything else must + * propagate unchanged. + */ + +import { BigNumber } from 'ethers'; +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; +import { EvmService } from 'src/integration/blockchain/shared/evm/evm.service'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutEvmService } from '../payout-evm.service'; + +// PayoutEvmService is abstract - it has no chain-specific behaviour of its own, so a bare +// subclass is enough to exercise it. +class PayoutEvmServiceWrapper extends PayoutEvmService {} + +describe('PayoutEvmService', () => { + let client: EvmClient; + let service: PayoutEvmService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + client = mock(); + const evmService = mock(); + jest.spyOn(evmService, 'getDefaultClient').mockReturnValue(client); + sendNativeCoinFromDexSpy = jest.spyOn(client, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(client, 'sendTokenFromDex'); + + service = new PayoutEvmServiceWrapper(evmService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('rpc rejected the tx'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('rpc rejected the tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount/nonce', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5, 7)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5, 7); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('rpc rejected the token tx'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('rpc rejected the token tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount/nonce', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5, 9)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5, 9); + }); + }); + + // Happy-path coverage for the read-side of the EVM payout lifecycle: completion polling, gas + // estimation, nonce lookup and expiry detection. The broadcast-boundary (sendNativeCoin/sendToken) + // is covered above; this is the "everything went fine" half of the same service. + describe('getPayoutCompletionData(...)', () => { + it('returns pending when there is no receipt yet (tx not yet mined)', async () => { + jest.spyOn(client, 'getTxReceipt').mockResolvedValue(null as any); + + const result = await service.getPayoutCompletionData('TX_NO_RECEIPT'); + + expect(client.getTxReceipt).toHaveBeenCalledWith('TX_NO_RECEIPT'); + expect(result).toEqual({ state: 'pending' }); + }); + + it('returns pending when the receipt has zero confirmations (reorg-vulnerable)', async () => { + jest.spyOn(client, 'getTxReceipt').mockResolvedValue({ confirmations: 0, status: 1 } as any); + + const result = await service.getPayoutCompletionData('TX_ZERO_CONF'); + + expect(result).toEqual({ state: 'pending' }); + }); + + it('returns complete with the actual fee when the receipt is confirmed and successful (status 1)', async () => { + jest.spyOn(client, 'getTxReceipt').mockResolvedValue({ confirmations: 3, status: 1 } as any); + jest.spyOn(client, 'getTxActualFee').mockResolvedValue(0.00042); + + const result = await service.getPayoutCompletionData('TX_OK'); + + expect(client.getTxActualFee).toHaveBeenCalledWith('TX_OK'); + expect(result).toEqual({ state: 'complete', fee: 0.00042 }); + }); + + it('returns failed + isOutOfGas=true when the tx reverted and consumed exactly the gas limit', async () => { + jest + .spyOn(client, 'getTxReceipt') + .mockResolvedValue({ confirmations: 5, status: 0, gasUsed: BigNumber.from(21000) } as any); + jest.spyOn(client, 'getTx').mockResolvedValue({ gasLimit: BigNumber.from(21000) } as any); + + const result = await service.getPayoutCompletionData('TX_OOG'); + + expect(client.getTx).toHaveBeenCalledWith('TX_OOG'); + expect(result).toEqual({ state: 'failed', isOutOfGas: true }); + }); + + it('returns failed + isOutOfGas=false when the tx reverted without exhausting the gas limit', async () => { + jest + .spyOn(client, 'getTxReceipt') + .mockResolvedValue({ confirmations: 5, status: 0, gasUsed: BigNumber.from(15000) } as any); + jest.spyOn(client, 'getTx').mockResolvedValue({ gasLimit: BigNumber.from(21000) } as any); + + const result = await service.getPayoutCompletionData('TX_REVERT'); + + expect(result).toEqual({ state: 'failed', isOutOfGas: false }); + }); + + it('returns failed + isOutOfGas=false when the failed tx can no longer be found (tx is null)', async () => { + jest + .spyOn(client, 'getTxReceipt') + .mockResolvedValue({ confirmations: 5, status: 0, gasUsed: BigNumber.from(15000) } as any); + jest.spyOn(client, 'getTx').mockResolvedValue(null as any); + + const result = await service.getPayoutCompletionData('TX_GONE'); + + expect(result).toEqual({ state: 'failed', isOutOfGas: false }); + }); + }); + + describe('getCurrentGasForCoinTransaction(...)', () => { + it('delegates to the client', async () => { + jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(123); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(123); + expect(client.getCurrentGasCostForCoinTransaction).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + it('delegates to the client, forwarding the token asset', async () => { + const token = createDefaultAsset(); + jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(456); + + await expect(service.getCurrentGasForTokenTransaction(token)).resolves.toBe(456); + expect(client.getCurrentGasCostForTokenTransaction).toHaveBeenCalledWith(token); + }); + }); + + describe('getTxNonce(...)', () => { + it('delegates to the client', async () => { + jest.spyOn(client, 'getTxNonce').mockResolvedValue(7); + + await expect(service.getTxNonce('TX_N')).resolves.toBe(7); + expect(client.getTxNonce).toHaveBeenCalledWith('TX_N'); + }); + }); + + describe('isTxExpired(...)', () => { + it('returns false without checking the tx when the RPC is stale (not synced, age >= 300s)', async () => { + jest.spyOn(client, 'getLatestBlockTimestamp').mockResolvedValue(Date.now() / 1000 - 1000); + const getTxSpy = jest.spyOn(client, 'getTx'); + + await expect(service.isTxExpired('TX_STALE_RPC')).resolves.toBe(false); + + expect(getTxSpy).not.toHaveBeenCalled(); + }); + + it('returns true when the RPC is synced and the tx can no longer be found (dropped from mempool)', async () => { + jest.spyOn(client, 'getLatestBlockTimestamp').mockResolvedValue(Date.now() / 1000 - 5); + jest.spyOn(client, 'getTx').mockResolvedValue(null as any); + + await expect(service.isTxExpired('TX_DROPPED')).resolves.toBe(true); + + expect(client.getTx).toHaveBeenCalledWith('TX_DROPPED'); + }); + + it('returns false when the RPC is synced and the tx is still found (not expired)', async () => { + jest.spyOn(client, 'getLatestBlockTimestamp').mockResolvedValue(Date.now() / 1000 - 5); + jest.spyOn(client, 'getTx').mockResolvedValue({ hash: 'TX_STILL_THERE' } as any); + + await expect(service.isTxExpired('TX_STILL_THERE')).resolves.toBe(false); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts new file mode 100644 index 0000000000..291bf0ff6d --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts @@ -0,0 +1,228 @@ +/** + * Unit Tests for PayoutFiroService + * + * Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + * FiroClient means the on-chain send was reached (tx may be in-flight) and must surface as + * PayoutBroadcastException so BitcoinBasedStrategy#send keeps the order PAYOUT_DESIGNATED + * (fail-closed) instead of rolling back and risking a double-spend on retry. + */ + +import { FiroClient } from 'src/integration/blockchain/firo/firo-client'; +import { FiroFeeService } from 'src/integration/blockchain/firo/services/firo-fee.service'; +import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutGroup } from '../base/payout-bitcoin-based.service'; +import { PayoutFiroService } from '../payout-firo.service'; + +describe('PayoutFiroService', () => { + let service: PayoutFiroService; + let mockClient: jest.Mocked; + let mockFeeService: jest.Mocked; + let sendManySpy: jest.Mock; + let mintSparkSpy: jest.Mock; + + beforeEach(() => { + sendManySpy = jest.fn().mockResolvedValue('TX_HASH_01'); + mintSparkSpy = jest.fn().mockResolvedValue('MINT_TX_HASH_01'); + + mockClient = { + sendMany: sendManySpy, + mintSpark: mintSparkSpy, + getInfo: jest.fn(), + getTx: jest.fn(), + } as unknown as jest.Mocked; + + const mockFiroService = { + getDefaultClient: jest.fn().mockReturnValue(mockClient), + } as unknown as jest.Mocked; + + mockFeeService = { + getSendFeeRate: jest.fn().mockResolvedValue(10), + } as unknown as jest.Mocked; + + service = new PayoutFiroService(mockFiroService, mockFeeService); + }); + + describe('sendUtxoToMany()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + + const result = await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + + expect(result).toBe('TX_HASH_01'); + }); + + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const cause = new TxBroadcastError('Firo RPC sendrawtransaction failed'); + sendManySpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Firo RPC sendrawtransaction failed'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const plainError = new Error('No non-deposit addresses with UTXOs available'); + sendManySpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('mintSpark()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'sparkAddr1', amount: 0.5 }]; + + const result = await service.mintSpark(payout); + + expect(result).toBe('MINT_TX_HASH_01'); + }); + + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'sparkAddr1', amount: 0.5 }]; + const cause = new TxBroadcastError('Firo RPC mintspark failed'); + mintSparkSpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.mintSpark(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Firo RPC mintspark failed'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'sparkAddr1', amount: 0.5 }]; + const plainError = new Error('mintspark returned no transaction IDs'); + mintSparkSpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.mintSpark(payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('isHealthy()', () => { + it('returns true when the node reports info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce({ blocks: 500000 }); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(mockClient.getInfo).toHaveBeenCalledTimes(1); + }); + + it('returns false when the node reports no info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce(null); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + + it('returns false (never throws) when the info call rejects', async () => { + (mockClient.getInfo as jest.Mock).mockRejectedValueOnce(new Error('node unreachable')); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + }); + + describe('getPayoutCompletionData()', () => { + it('returns [true, negated fee] once the tx is mined (has blockhash and confirmations)', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + blockhash: 'BLOCK_HASH_01', + confirmations: 6, + time: 0, + amount: 0.5, + fee: -0.001, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.001]); + expect(mockClient.getTx).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('defaults the fee to 0 for a mined tx that carries no fee field', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + blockhash: 'BLOCK_HASH_01', + confirmations: 6, + time: 0, + amount: 0.5, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBe(true); + expect(result[1]).toBe(-0); // -(undefined ?? 0) + }); + + it('treats a tx with zero confirmations as not complete', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + blockhash: 'BLOCK_HASH_01', + confirmations: 0, + time: 0, + amount: 0.5, + fee: -0.001, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBe(false); + expect(result[1]).toBe(0); + }); + + it('treats an unmined tx (no blockhash) as not complete', async () => { + mockClient.getTx.mockResolvedValueOnce({ txid: 'TX_HASH_01', confirmations: 6, time: 0, amount: 0.5 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBeUndefined(); + expect(result[1]).toBe(0); + }); + + it('treats a missing tx as not complete', async () => { + mockClient.getTx.mockResolvedValueOnce(null); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBeNull(); + expect(result[1]).toBe(0); + }); + }); + + describe('getCurrentFeeRate()', () => { + it('delegates to the fee service', async () => { + await expect(service.getCurrentFeeRate()).resolves.toBe(10); + expect(mockFeeService.getSendFeeRate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-icp.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-icp.service.spec.ts new file mode 100644 index 0000000000..8f888b6cc4 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-icp.service.spec.ts @@ -0,0 +1,178 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutInternetComputerService: sendNativeCoin/ + * sendToken catch a TxBroadcastError from the (shared, non-payout-specific) InternetComputerClient + * and re-throw it as a PayoutBroadcastException, so the payout strategy can tell "the IC update call + * was reached" apart from a provable pre-broadcast failure (see InternetComputerStrategy#doPayout + * via PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutInternetComputerService } from '../payout-icp.service'; + +describe('PayoutInternetComputerService', () => { + let client: InternetComputerClient; + let service: PayoutInternetComputerService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + client = mock(); + const internetComputerService = mock(); + jest.spyOn(internetComputerService, 'getDefaultClient').mockReturnValue(client); + sendNativeCoinFromDexSpy = jest.spyOn(client, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(client, 'sendTokenFromDex'); + + service = new PayoutInternetComputerService(internetComputerService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('IC update call rejected'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('IC update call rejected'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('IC update call rejected for token transfer'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('IC update call rejected for token transfer'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [false, 0] and reads no fee while the tx is not complete', async () => { + jest.spyOn(client, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee'); + const tokenGasSpy = jest.spyOn(client, 'getCurrentGasCostForTokenTransaction'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + expect(tokenGasSpy).not.toHaveBeenCalled(); + }); + + it('reads the reverse-gas-model token cost (not the on-chain fee) for a complete token payout', async () => { + const token = createDefaultAsset(); + jest.spyOn(client, 'isTxComplete').mockResolvedValue(true); + const tokenGasSpy = jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(0.0002); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01', token)).resolves.toEqual([true, 0.0002]); + expect(tokenGasSpy).toHaveBeenCalledWith(token); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + + it('reads the actual on-chain fee for a complete coin payout (no token)', async () => { + jest.spyOn(client, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee').mockResolvedValue(0.0001); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.0001]); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('falls back to the coin gas cost when the primary fee lookup throws', async () => { + jest.spyOn(client, 'isTxComplete').mockResolvedValue(true); + jest.spyOn(client, 'getTxActualFee').mockRejectedValue(new Error('ledger query failed')); + const coinGasSpy = jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(0.00005); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.00005]); + expect(coinGasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForCoinTransaction()', () => { + it('delegates to the default client', async () => { + const gasSpy = jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(0.0001); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(0.0001); + expect(gasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + const asset = createDefaultAsset(); + + it('delegates to the default client, forwarding the token', async () => { + const gasSpy = jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(0.0002); + + await expect(service.getCurrentGasForTokenTransaction(asset)).resolves.toBe(0.0002); + expect(gasSpy).toHaveBeenCalledWith(asset); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts new file mode 100644 index 0000000000..c73ff69172 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutLightningService: sendPayment catches a + * TxBroadcastError from the (shared, non-payout-specific - also used by PayIn) LightningService and + * re-throws it as a PayoutBroadcastException, so the payout strategy can tell "the LND send-payment + * call was reached" apart from a provable pre-broadcast/in-band failure (see LightningStrategy# + * doPayout via PayoutStrategy#handleBroadcastError). Anything else - including the plain Error + * LightningService#sendTransfer throws for an in-band LND payment_error ("no route" etc., see + * lightning.service.ts) - must propagate unchanged so the order self-heals. + * + * One exception: a keysend (LN_NID) payment carries no invoice payment_hash, so LND cannot + * deduplicate a re-broadcast. Every keysend failure is therefore wrapped fail-closed (as a + * PayoutBroadcastException), even a plain in-band error, to prevent a double-pay on retry. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { LndRouteDto } from 'src/integration/lightning/dto/lnd.dto'; +import { LightningClient } from 'src/integration/lightning/lightning-client'; +import { LightningService } from 'src/integration/lightning/services/lightning.service'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutLightningService } from '../payout-lightning.service'; + +describe('PayoutLightningService', () => { + let lightningService: LightningService; + let client: LightningClient; + let service: PayoutLightningService; + let sendTransferSpy: jest.SpyInstance; + + beforeEach(() => { + lightningService = mock(); + client = mock(); + jest.spyOn(lightningService, 'getDefaultClient').mockReturnValue(client); + sendTransferSpy = jest.spyOn(lightningService, 'sendTransfer'); + + service = new PayoutLightningService(lightningService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendPayment(...)', () => { + it('wraps a TxBroadcastError (transport/timeout to the LND node) into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('socket hang up'); + sendTransferSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendPayment('ADDR_01', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('socket hang up'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates an in-band "no route" payment failure (plain Error) unchanged, so the order self-heals', async () => { + const cause = new Error('Error while sending payment by LNURL ADDR_01: FAILURE_REASON_NO_ROUTE'); + sendTransferSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendPayment('ADDR_01', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('wraps a keysend (LN_NID) in-band failure fail-closed, since it has no payment_hash for LND dedup', async () => { + const cause = new Error('Error while sending payment by LNURL LNNID03aabb: FAILURE_REASON_NO_ROUTE'); + sendTransferSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendPayment('LNNID03aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe(cause.message); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('wraps a keysend (LN_NID) non-Error rejection fail-closed, stringifying the reason', async () => { + sendTransferSpy.mockRejectedValue('raw string failure'); + + let error: unknown; + try { + await service.sendPayment('LNNID03aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('raw string failure'); + expect((error as PayoutBroadcastException).cause).toBe('raw string failure'); + }); + + it('propagates any other non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTransferSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendPayment('ADDR_01', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx id on success and forwards address/amount', async () => { + sendTransferSpy.mockResolvedValue('TX_ID_01'); + + await expect(service.sendPayment('ADDR_01', 0.001)).resolves.toBe('TX_ID_01'); + expect(sendTransferSpy).toHaveBeenCalledWith('ADDR_01', 0.001); + }); + + it('returns the tx id for a keysend (LN_NID) with a non-empty payment hash', async () => { + sendTransferSpy.mockResolvedValue('TX_ID_KEYSEND'); + + await expect(service.sendPayment('LNNID03aabbccddeeff', 0.001)).resolves.toBe('TX_ID_KEYSEND'); + }); + + it('wraps a keysend (LN_NID) empty payment hash fail-closed, since a re-broadcast has no dedup', async () => { + sendTransferSpy.mockResolvedValue(''); + + let error: unknown; + try { + await service.sendPayment('LNNID03aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Lightning keysend returned an empty payment hash'); + }); + + it('returns an empty payment hash unchanged for a non-keysend (invoice payment_hash dedup covers a retry)', async () => { + sendTransferSpy.mockResolvedValue(''); + + await expect(service.sendPayment('ADDR_01', 0.001)).resolves.toBe(''); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the default client', async () => { + const isHealthySpy = jest.spyOn(client, 'isHealthy').mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getEstimatedFee(...)', () => { + const route = (totalFeesMsat: number): LndRouteDto => ({ + total_fees_msat: totalFeesMsat, + total_amt_msat: totalFeesMsat + 1000, + hops: [{ chan_id: '1', amt_to_forward_msat: 1000, fee_msat: totalFeesMsat, pub_key: 'HOP_PK' }], + }); + + it('resolves the public key, queries routes and returns the most expensive route fee in BTC', async () => { + const getPublicKeySpy = jest.spyOn(lightningService, 'getPublicKeyOfAddress').mockResolvedValue('PUBKEY_01'); + const getRoutesSpy = jest.spyOn(client, 'getLndRoutes').mockResolvedValue([route(1000), route(2000)]); + + // max(1000, 2000, 0) msat = 2000 msat = 2 sat = 0.00000002 BTC + await expect(service.getEstimatedFee('addr@dfx.swiss', 0.001)).resolves.toBe(0.00000002); + expect(getPublicKeySpy).toHaveBeenCalledWith('addr@dfx.swiss'); + expect(getRoutesSpy).toHaveBeenCalledWith('PUBKEY_01', 0.001); + }); + + it('returns 0 when no route is available (Math.max seed)', async () => { + jest.spyOn(lightningService, 'getPublicKeyOfAddress').mockResolvedValue('PUBKEY_01'); + jest.spyOn(client, 'getLndRoutes').mockResolvedValue([]); + + await expect(service.getEstimatedFee('addr@dfx.swiss', 0.001)).resolves.toBe(0); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('delegates to the shared lightning service', async () => { + const completionSpy = jest.spyOn(lightningService, 'getTransferCompletionData'); + completionSpy.mockResolvedValue([true, 0.00000002]); + + await expect(service.getPayoutCompletionData('TX_ID_01')).resolves.toEqual([true, 0.00000002]); + expect(completionSpy).toHaveBeenCalledWith('TX_ID_01'); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-monero.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-monero.service.spec.ts new file mode 100644 index 0000000000..2a97fb5cfd --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-monero.service.spec.ts @@ -0,0 +1,154 @@ +/** + * Unit Tests for PayoutMoneroService + * + * Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + * MoneroClient means the on-chain send was reached (tx may already be relayed) and must surface + * as PayoutBroadcastException so BitcoinBasedStrategy#send keeps the order PAYOUT_DESIGNATED + * (fail-closed) instead of rolling back and risking a double-spend on retry. + */ + +import { MoneroClient } from 'src/integration/blockchain/monero/monero-client'; +import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutGroup } from '../base/payout-bitcoin-based.service'; +import { PayoutMoneroService } from '../payout-monero.service'; + +describe('PayoutMoneroService', () => { + let service: PayoutMoneroService; + let mockClient: jest.Mocked; + let sendTransfersSpy: jest.Mock; + let getTransactionSpy: jest.Mock; + let getUnlockedBalanceSpy: jest.Mock; + let getFeeEstimateSpy: jest.Mock; + let isHealthySpy: jest.Mock; + + beforeEach(() => { + sendTransfersSpy = jest.fn().mockResolvedValue({ txid: 'TX_HASH_01', amount: 1.5, fee: 0.01 }); + getTransactionSpy = jest.fn(); + getUnlockedBalanceSpy = jest.fn(); + getFeeEstimateSpy = jest.fn(); + isHealthySpy = jest.fn(); + + mockClient = { + sendTransfers: sendTransfersSpy, + getTransaction: getTransactionSpy, + getUnlockedBalance: getUnlockedBalanceSpy, + getFeeEstimate: getFeeEstimateSpy, + } as unknown as jest.Mocked; + + const mockMoneroService = { + getDefaultClient: jest.fn().mockReturnValue(mockClient), + isHealthy: isHealthySpy, + } as unknown as jest.Mocked; + + service = new PayoutMoneroService(mockMoneroService); + }); + + describe('sendToMany()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + + const result = await service.sendToMany(PayoutOrderContext.BUY_CRYPTO, payout); + + expect(result).toBe('TX_HASH_01'); + }); + + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const cause = new TxBroadcastError('Failed to send tx'); + sendTransfersSpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Failed to send tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const plainError = new Error('unrelated client error'); + sendTransfersSpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('throws a descriptive error when a successful call returns no transfer', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + sendTransfersSpy.mockResolvedValueOnce(undefined); + + await expect(service.sendToMany(PayoutOrderContext.BUY_CRYPTO, payout)).rejects.toThrow( + 'Error while sending payment by Monero ADDR_01', + ); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the shared service', async () => { + isHealthySpy.mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUnlockedBalance()', () => { + it('delegates to the default client', async () => { + getUnlockedBalanceSpy.mockResolvedValue(3.5); + + await expect(service.getUnlockedBalance()).resolves.toBe(3.5); + expect(getUnlockedBalanceSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPayoutCompletionData()', () => { + it('returns [true, txnFee] once the tx is mined with confirmations', async () => { + getTransactionSpy.mockResolvedValue({ block_height: 100, confirmations: 5, txnFee: 0.0002 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.0002]); + expect(getTransactionSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('defaults the fee to 0 for a complete tx that carries no txnFee', async () => { + getTransactionSpy.mockResolvedValue({ block_height: 100, confirmations: 5 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0]); + }); + + it('returns [false, 0] while the tx has no confirmations', async () => { + getTransactionSpy.mockResolvedValue({ block_height: 100, confirmations: 0, txnFee: 0.0002 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([false, 0]); + }); + }); + + describe('getEstimatedFee()', () => { + it('returns the slow-priority base fee from the estimate', async () => { + getFeeEstimateSpy.mockResolvedValue({ fee: 0, fees: [0.00012, 0.0002, 0.0003, 0.0004], status: 'OK' }); + + await expect(service.getEstimatedFee()).resolves.toBe(0.00012); + expect(getFeeEstimateSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-solana.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-solana.service.spec.ts new file mode 100644 index 0000000000..8d8df5c8bb --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-solana.service.spec.ts @@ -0,0 +1,158 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutSolanaService: sendNativeCoin/sendToken + * catch a TxBroadcastError from the (shared, non-payout-specific) SolanaClient and re-throw it as a + * PayoutBroadcastException, so the payout strategy can tell "the RPC send call was reached" apart + * from a provable pre-broadcast failure (see SolanaStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; +import { SolanaClient } from 'src/integration/blockchain/solana/solana-client'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutSolanaService } from '../payout-solana.service'; + +describe('PayoutSolanaService', () => { + let client: SolanaClient; + let service: PayoutSolanaService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + client = mock(); + const solanaService = mock(); + jest.spyOn(solanaService, 'getDefaultClient').mockReturnValue(client); + sendNativeCoinFromDexSpy = jest.spyOn(client, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(client, 'sendTokenFromDex'); + + service = new PayoutSolanaService(solanaService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('rpc rejected the tx'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('rpc rejected the tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('rpc rejected the token tx'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('rpc rejected the token tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] and reads the actual fee once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(client, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee').mockResolvedValue(0.000005); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.000005]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + const isTxCompleteSpy = jest.spyOn(client, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentGasForCoinTransaction()', () => { + it('delegates to the default client', async () => { + const gasSpy = jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(0.000001); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(0.000001); + expect(gasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + const asset = createDefaultAsset(); + + it('delegates to the default client, forwarding the token', async () => { + const gasSpy = jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(0.000002); + + await expect(service.getCurrentGasForTokenTransaction(asset)).resolves.toBe(0.000002); + expect(gasSpy).toHaveBeenCalledWith(asset); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-spark.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-spark.service.spec.ts new file mode 100644 index 0000000000..676aaac290 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-spark.service.spec.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutSparkService: sendTransaction catches a + * TxBroadcastError from the (shared, non-payout-specific) SparkService/SparkClient and re-throws it + * as a PayoutBroadcastException, so the payout strategy can tell "wallet.transfer was reached" apart + * from a provable pre-broadcast failure (see SparkStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { SparkClient } from 'src/integration/blockchain/spark/spark-client'; +import { SparkService } from 'src/integration/blockchain/spark/spark.service'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutSparkService } from '../payout-spark.service'; + +describe('PayoutSparkService', () => { + let sparkService: SparkService; + let sparkClient: SparkClient; + let service: PayoutSparkService; + let sendTransactionSpy: jest.SpyInstance; + + beforeEach(() => { + sparkService = mock(); + sparkClient = mock(); + jest.spyOn(sparkService, 'getDefaultClient').mockReturnValue(sparkClient); + sendTransactionSpy = jest.spyOn(sparkService, 'sendTransaction'); + + service = new PayoutSparkService(sparkService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendTransaction(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('transfer failed'); + sendTransactionSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendTransaction('SPARK_ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('transfer failed'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTransactionSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendTransaction('SPARK_ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the txid on success and forwards address/amount', async () => { + sendTransactionSpy.mockResolvedValue({ txid: 'TX_HASH_01', fee: 0 }); + + await expect(service.sendTransaction('SPARK_ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendTransactionSpy).toHaveBeenCalledWith('SPARK_ADDR_01', 1.5); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the shared service', async () => { + const isHealthySpy = jest.spyOn(sparkService, 'isHealthy').mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(sparkClient, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(sparkService, 'getTxActualFee').mockResolvedValue(0.0001); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.0001]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + jest.spyOn(sparkClient, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(sparkService, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentFeeForTransaction(...)', () => { + it('returns the native fee for a coin asset', async () => { + const coin = createCustomAsset({ type: AssetType.COIN }); + const getNativeFeeSpy = jest.spyOn(sparkService, 'getNativeFee').mockResolvedValue(0.00001); + + await expect(service.getCurrentFeeForTransaction(coin)).resolves.toBe(0.00001); + expect(getNativeFeeSpy).toHaveBeenCalledTimes(1); + }); + + it('throws for a non-coin asset without touching the native fee', () => { + const token = createDefaultAsset(); // AssetType.TOKEN + const getNativeFeeSpy = jest.spyOn(sparkService, 'getNativeFee'); + + expect(() => service.getCurrentFeeForTransaction(token)).toThrow('Method not implemented'); + expect(getNativeFeeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-tron.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-tron.service.spec.ts new file mode 100644 index 0000000000..c0df2aae02 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-tron.service.spec.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutTronService: sendNativeCoin/sendToken catch + * a TxBroadcastError from the (shared, non-payout-specific) TronService/TronClient and re-throw it as + * a PayoutBroadcastException, so the payout strategy can tell "the atomic sign+broadcast gateway call + * was reached" apart from a provable pre-broadcast failure (see TronStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { TronService } from 'src/integration/blockchain/tron/services/tron.service'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutTronService } from '../payout-tron.service'; + +describe('PayoutTronService', () => { + let tronService: TronService; + let service: PayoutTronService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + tronService = mock(); + sendNativeCoinFromDexSpy = jest.spyOn(tronService, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(tronService, 'sendTokenFromDex'); + + service = new PayoutTronService(tronService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('gateway rejected the tx'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('gateway rejected the tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('gateway rejected the trc20 tx'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('gateway rejected the trc20 tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] and reads the actual fee once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(tronService, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(tronService, 'getTxActualFee').mockResolvedValue(1.1); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 1.1]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + const isTxCompleteSpy = jest.spyOn(tronService, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(tronService, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentGasForCoinTransaction()', () => { + it('delegates to the shared service', async () => { + const gasSpy = jest.spyOn(tronService, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(1.5); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(1.5); + expect(gasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + const asset = createDefaultAsset(); + + it('delegates to the shared service, forwarding the token', async () => { + const gasSpy = jest.spyOn(tronService, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(2.7); + + await expect(service.getCurrentGasForTokenTransaction(asset)).resolves.toBe(2.7); + expect(gasSpy).toHaveBeenCalledWith(asset); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-zano.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-zano.service.spec.ts new file mode 100644 index 0000000000..71d97fbd26 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-zano.service.spec.ts @@ -0,0 +1,218 @@ +/** + * Unit Tests for PayoutZanoService + * + * Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + * ZanoService/ZanoClient means the on-chain send was reached (tx may already be relayed) and must + * surface as PayoutBroadcastException so ZanoStrategy#send (via BitcoinBasedStrategy) keeps the + * order PAYOUT_DESIGNATED (fail-closed) instead of rolling back and risking a double-spend. + */ + +import { ZanoTransactionDto } from 'src/integration/blockchain/zano/dto/zano.dto'; +import { ZanoService } from 'src/integration/blockchain/zano/services/zano.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutGroup } from '../base/payout-bitcoin-based.service'; +import { PayoutZanoService } from '../payout-zano.service'; + +describe('PayoutZanoService', () => { + let service: PayoutZanoService; + let sendCoinsSpy: jest.Mock; + let sendTokensSpy: jest.Mock; + let isHealthySpy: jest.Mock; + let getUnlockedCoinBalanceSpy: jest.Mock; + let getUnlockedTokenBalanceSpy: jest.Mock; + let getTransactionSpy: jest.Mock; + let isTxCompleteSpy: jest.Mock; + let getFeeEstimateSpy: jest.Mock; + + beforeEach(() => { + sendCoinsSpy = jest.fn().mockResolvedValue({ txId: 'COIN_TX_01', amount: 1.5, fee: 0.01 }); + sendTokensSpy = jest.fn().mockResolvedValue({ txId: 'TOKEN_TX_01', amount: 1.5, fee: 0.01 }); + isHealthySpy = jest.fn(); + getUnlockedCoinBalanceSpy = jest.fn(); + getUnlockedTokenBalanceSpy = jest.fn(); + getTransactionSpy = jest.fn(); + isTxCompleteSpy = jest.fn(); + getFeeEstimateSpy = jest.fn(); + + const mockZanoService = { + sendCoins: sendCoinsSpy, + sendTokens: sendTokensSpy, + isHealthy: isHealthySpy, + getUnlockedCoinBalance: getUnlockedCoinBalanceSpy, + getUnlockedTokenBalance: getUnlockedTokenBalanceSpy, + getTransaction: getTransactionSpy, + isTxComplete: isTxCompleteSpy, + getFeeEstimate: getFeeEstimateSpy, + } as unknown as jest.Mocked; + + service = new PayoutZanoService(mockZanoService); + }); + + describe('sendCoins()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + + const result = await service.sendCoins(payout); + + expect(result).toBe('COIN_TX_01'); + }); + + it('should wrap a TxBroadcastError from the service into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const cause = new TxBroadcastError('Transfer not sent: response was {}'); + sendCoinsSpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Transfer not sent: response was {}'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the service unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const plainError = new Error('Unlocked coin balance 0 less than amount + fee 1.51'); + sendCoinsSpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('sendTokens()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + + const result = await service.sendTokens(payout, createDefaultAsset()); + + expect(result).toBe('TOKEN_TX_01'); + }); + + it('should wrap a TxBroadcastError from the service into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const cause = new TxBroadcastError('Transfer not sent: response was {}'); + sendTokensSpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendTokens(payout, createDefaultAsset()); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Transfer not sent: response was {}'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the service unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const plainError = new Error('Unlocked token balance 0 less than token amount 1.5'); + sendTokensSpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendTokens(payout, createDefaultAsset()); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the shared service', async () => { + isHealthySpy.mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUnlockedCoinBalance()', () => { + it('delegates to the shared service', async () => { + getUnlockedCoinBalanceSpy.mockResolvedValue(12.5); + + await expect(service.getUnlockedCoinBalance()).resolves.toBe(12.5); + expect(getUnlockedCoinBalanceSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUnlockedTokenBalance(...)', () => { + it('delegates to the shared service, forwarding the token', async () => { + const token = createDefaultAsset(); + getUnlockedTokenBalanceSpy.mockResolvedValue(7.25); + + await expect(service.getUnlockedTokenBalance(token)).resolves.toBe(7.25); + expect(getUnlockedTokenBalanceSpy).toHaveBeenCalledWith(token); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + const completeTx: ZanoTransactionDto = { + id: 'TX_HASH_01', + block: 100, + amount: 1.5, + fee: 0.01, + status: 'confirmed', + timestamp: '1700000000', + }; + + it('returns [true, fee] read from the fetched tx once it is complete', async () => { + getTransactionSpy.mockResolvedValue(completeTx); + isTxCompleteSpy.mockResolvedValue(true); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.01]); + expect(getTransactionSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] while the tx is not complete', async () => { + getTransactionSpy.mockResolvedValue(completeTx); + isTxCompleteSpy.mockResolvedValue(false); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([false, 0]); + }); + + it('defaults the fee to 0 for a complete but fee-less tx (defensive ?? guard)', async () => { + // fee is a required field on ZanoTransactionDto, so simulate a malformed/fee-less response + // to exercise the `transaction.fee ?? 0` fallback branch. + getTransactionSpy.mockResolvedValue({ ...completeTx, fee: undefined } as unknown as ZanoTransactionDto); + isTxCompleteSpy.mockResolvedValue(true); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0]); + }); + }); + + describe('getEstimatedFee()', () => { + it('delegates to the shared service fee estimate', () => { + getFeeEstimateSpy.mockReturnValue(0.005); + + expect(service.getEstimatedFee()).toBe(0.005); + expect(getFeeEstimateSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts new file mode 100644 index 0000000000..3231b0cca9 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts @@ -0,0 +1,873 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { mock } from 'jest-mock-extended'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import * as processServiceModule from 'src/shared/services/process.service'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { In, MoreThan } from 'typeorm'; +import { createCustomPayoutOrder } from '../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../entities/payout-order.entity'; +import { PayoutOrderFactory } from '../../factories/payout-order.factory'; +import { PayoutRequest } from '../../interfaces'; +import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; +import { PayoutStrategy } from '../../strategies/payout/impl/base/payout.strategy'; +import { PayoutStrategyRegistry } from '../../strategies/payout/impl/base/payout.strategy-registry'; +import { PrepareStrategyRegistry } from '../../strategies/prepare/impl/base/prepare.strategy-registry'; +import { PayoutLogService } from '../payout-log.service'; +import { PayoutService } from '../payout.service'; + +describe('PayoutService', () => { + describe('#speedupTransaction(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutStrategyRegistry: PayoutStrategyRegistry; + let doPayoutSpy: jest.Mock; + let disabledProcessSpy: jest.SpyInstance; + + // supportsSpeedup is a read-only getter; build a plain typed stub instead of a jest-mock-extended + // proxy so the boolean is deterministic (the proxy would auto-mock the getter to a truthy fn). + function setupStrategy(supportsSpeedup: boolean): void { + doPayoutSpy = jest.fn(); + const strategy = { supportsSpeedup, doPayout: doPayoutSpy } as unknown as PayoutStrategy; + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockReturnValue(strategy); + } + + beforeEach(() => { + payoutOrderRepo = mock(); + payoutStrategyRegistry = mock(); + + // TX_SPEEDUP is fail-closed (disabled) by default in tests; enable it so the speedup path runs. + disabledProcessSpy = jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + + // Default to a speedup-capable (EVM-like) strategy. + setupStrategy(true); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + payoutStrategyRegistry, + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('rejects a speedup on an order that has not been broadcast yet (guards against a fresh double-payout)', async () => { + const order = createCustomPayoutOrder({ id: 1, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + await expect(service.speedupTransaction(order.id)).rejects.toThrow(BadRequestException); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + + it('accelerates an already-broadcast (PAYOUT_PENDING) order on a speedup-capable (EVM) strategy', async () => { + const order = createCustomPayoutOrder({ + id: 2, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'PTX_01', + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + await service.speedupTransaction(order.id); + + expect(doPayoutSpy).toHaveBeenCalledWith([order]); + }); + + it('rejects a speedup on a chain without replacement semantics (supportsSpeedup=false) and never broadcasts', async () => { + setupStrategy(false); + const order = createCustomPayoutOrder({ + id: 3, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'PTX_02', + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + await expect(service.speedupTransaction(order.id)).rejects.toThrow(BadRequestException); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + + it('rejects a speedup when TX_SPEEDUP is disabled, even on a speedup-capable strategy', async () => { + disabledProcessSpy.mockReturnValue(true); + const order = createCustomPayoutOrder({ + id: 4, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'PTX_03', + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + await expect(service.speedupTransaction(order.id)).rejects.toThrow(BadRequestException); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException for an unknown order id and never consults the strategy registry', async () => { + const getStrategySpy = jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy'); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(null); + + await expect(service.speedupTransaction(999)).rejects.toThrow(NotFoundException); + expect(getStrategySpy).not.toHaveBeenCalled(); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + }); + + describe('#processOrders(...) — cron-level reboot guarantee', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutStrategyRegistry: PayoutStrategyRegistry; + let notificationService: NotificationService; + let doPayoutSpy: jest.Mock; + + // Dispatches payoutOrderRepo.findBy(...) by the status it was queried with, mirroring the + // distinct status-scoped queries each cron sub-step (checkExistingOrders/payoutOrders/ + // processFailedOrders) issues against the same table. + function mockFindByStatus(map: Partial>): void { + jest.spyOn(payoutOrderRepo, 'findBy').mockImplementation(async (where: { status?: PayoutOrderStatus }) => { + return (where.status && map[where.status]) ?? []; + }); + } + + beforeEach(() => { + payoutOrderRepo = mock(); + payoutStrategyRegistry = mock(); + notificationService = mock(); + + // getLatestOrderDate() resolves to "now" so the debounce (waitForStableInput) is NOT yet + // elapsed: prepareNewOrders() returns early and never queries findBy({status: CREATED}), + // which is irrelevant to the reboot guarantee under test here. + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue({ created: new Date() } as PayoutOrder); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + + doPayoutSpy = jest.fn(); + const strategy = { supportsSpeedup: false, doPayout: doPayoutSpy } as unknown as PayoutStrategy; + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockReturnValue(strategy); + + service = new PayoutService( + mock(), + notificationService, + payoutOrderRepo, + mock(), + payoutStrategyRegistry, + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('dispatches only PREPARATION_CONFIRMED orders to doPayout; a crashed PAYOUT_DESIGNATED order is skipped', async () => { + const confirmedOrder = createCustomPayoutOrder({ id: 10, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + const crashedOrder = createCustomPayoutOrder({ id: 11, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + + mockFindByStatus({ + [PayoutOrderStatus.PREPARATION_PENDING]: [], + [PayoutOrderStatus.PAYOUT_PENDING]: [], + [PayoutOrderStatus.PREPARATION_CONFIRMED]: [confirmedOrder], + [PayoutOrderStatus.PAYOUT_DESIGNATED]: [crashedOrder], + }); + + await service.processOrders(); + + expect(doPayoutSpy).toHaveBeenCalledTimes(1); + expect(doPayoutSpy).toHaveBeenCalledWith([confirmedOrder]); + expect(doPayoutSpy).not.toHaveBeenCalledWith(expect.arrayContaining([crashedOrder])); + }); + + it('processFailedOrders marks a stuck order PAYOUT_UNCERTAIN, persists it and alerts via mail (no doPayout)', async () => { + const crashedOrder = createCustomPayoutOrder({ id: 12, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([crashedOrder]); + + await service['processFailedOrders'](); + + expect(crashedOrder.status).toBe(PayoutOrderStatus.PAYOUT_UNCERTAIN); + expect(saveSpy).toHaveBeenCalledWith(crashedOrder); + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + + describe('#payoutOrders(...)', () => { + it('queries PREPARATION_CONFIRMED orders, resolves a strategy per order and dispatches doPayout with the grouped orders', async () => { + const confirmedOrder = createCustomPayoutOrder({ id: 20, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([confirmedOrder]); + const getStrategySpy = jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy'); + + await service['payoutOrders'](); + + expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + expect(getStrategySpy).toHaveBeenCalledWith(confirmedOrder.asset); + expect(doPayoutSpy).toHaveBeenCalledTimes(1); + expect(doPayoutSpy).toHaveBeenCalledWith([confirmedOrder]); + }); + + it('skips an order without a resolvable strategy and logs a warning (groupByStrategies branch)', async () => { + const orphanOrder = createCustomPayoutOrder({ id: 21, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orphanOrder]); + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockReturnValue(undefined); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + await service['payoutOrders'](); + + expect(doPayoutSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`for payout order ID ${orphanOrder.id}`)); + }); + + it('isolates a throwing payout strategy: logs the error and continues without crashing the cron step (catch branch)', async () => { + const confirmedOrder = createCustomPayoutOrder({ id: 22, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([confirmedOrder]); + const throwingDoPayout = jest.fn().mockRejectedValue(new Error('boom')); + jest + .spyOn(payoutStrategyRegistry, 'getPayoutStrategy') + .mockReturnValue({ doPayout: throwingDoPayout } as unknown as PayoutStrategy); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['payoutOrders']()).resolves.toBeUndefined(); + + expect(throwingDoPayout).toHaveBeenCalledWith([confirmedOrder]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('isolates a throwing payout-strategy group from a second, healthy strategy group (cross-group isolation)', async () => { + const assetA = createCustomAsset({ id: 201 }); + const assetB = createCustomAsset({ id: 202 }); + const orderA = createCustomPayoutOrder({ + id: 23, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + asset: assetA, + }); + const orderB = createCustomPayoutOrder({ + id: 24, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + asset: assetB, + }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB]); + + const throwingDoPayout = jest.fn().mockRejectedValue(new Error('boom')); + const healthyDoPayout = jest.fn().mockResolvedValue(undefined); + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockImplementation((asset) => { + if (asset === assetA) return { doPayout: throwingDoPayout } as unknown as PayoutStrategy; + if (asset === assetB) return { doPayout: healthyDoPayout } as unknown as PayoutStrategy; + return undefined; + }); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['payoutOrders']()).resolves.toBeUndefined(); + + expect(throwingDoPayout).toHaveBeenCalledWith([orderA]); + // group B's doPayout still runs despite group A's throw: proves catch-isolation across groups, not merely within one + expect(healthyDoPayout).toHaveBeenCalledWith([orderB]); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('#processFailedOrders(...)', () => { + it('does nothing when there is no PAYOUT_DESIGNATED order (no mail, no save)', async () => { + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([]); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail'); + + await service['processFailedOrders'](); + + expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + expect(sendMailSpy).not.toHaveBeenCalled(); + expect(saveSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('#getPayoutOrders(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + + beforeEach(() => { + payoutOrderRepo = mock(); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('queries orders created after the given date and forwards the requested relations', async () => { + const from = new Date('2026-01-01'); + const relations = { asset: true }; + const orders = [createCustomPayoutOrder({ id: 100 })]; + const findSpy = jest.spyOn(payoutOrderRepo, 'find').mockResolvedValue(orders); + + const result = await service.getPayoutOrders(from, relations); + + expect(findSpy).toHaveBeenCalledWith({ where: { created: MoreThan(from) }, relations }); + expect(result).toBe(orders); + }); + + it('queries orders without relations when none are provided', async () => { + const from = new Date('2026-01-01'); + const findSpy = jest.spyOn(payoutOrderRepo, 'find').mockResolvedValue([]); + + await service.getPayoutOrders(from); + + expect(findSpy).toHaveBeenCalledWith({ where: { created: MoreThan(from) }, relations: undefined }); + }); + }); + + describe('#doPayout(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutOrderFactory: PayoutOrderFactory; + let disabledProcessSpy: jest.SpyInstance; + + const baseRequest: PayoutRequest = { + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: 'CID_99', + asset: createDefaultAsset(), + amount: 5, + destinationAddress: 'ADDR_99', + }; + + beforeEach(() => { + payoutOrderRepo = mock(); + payoutOrderFactory = mock(); + + // CRYPTO_PAYOUT is fail-closed (disabled) by default in tests; enable it so the happy path runs. + disabledProcessSpy = jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + payoutOrderFactory, + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('creates and persists a new payout order when the process is enabled and the amount is valid', async () => { + const order = createCustomPayoutOrder({ id: 101 }); + const createSpy = jest.spyOn(payoutOrderFactory, 'createOrder').mockReturnValue(order); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save').mockResolvedValue(order); + + await service.doPayout(baseRequest); + + expect(createSpy).toHaveBeenCalledWith(baseRequest); + expect(saveSpy).toHaveBeenCalledWith(order); + }); + + it('accepts a zero-amount payout (boundary of the amount >= 0 guard)', async () => { + const zeroRequest = { ...baseRequest, amount: 0 }; + const order = createCustomPayoutOrder({ id: 102, amount: 0 }); + jest.spyOn(payoutOrderFactory, 'createOrder').mockReturnValue(order); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save').mockResolvedValue(order); + + await service.doPayout(zeroRequest); + + expect(saveSpy).toHaveBeenCalledWith(order); + }); + + it('rejects with a generic error and logs when CRYPTO_PAYOUT is disabled (fail-closed)', async () => { + disabledProcessSpy.mockReturnValue(true); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service.doPayout(baseRequest)).rejects.toThrow( + `Error while trying to create PayoutOrder for context ${baseRequest.context} and correlationId: ${baseRequest.correlationId}`, + ); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('rejects with a generic error and logs when the requested amount is negative', async () => { + const negativeRequest = { ...baseRequest, amount: -1 }; + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service.doPayout(negativeRequest)).rejects.toThrow( + `Error while trying to create PayoutOrder for context ${negativeRequest.context} and correlationId: ${negativeRequest.correlationId}`, + ); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('#checkOrderCompletion(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + + beforeEach(() => { + payoutOrderRepo = mock(); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('reports isComplete=true with the payout details for a COMPLETE order', async () => { + const order = createCustomPayoutOrder({ + id: 110, + status: PayoutOrderStatus.COMPLETE, + payoutTxId: 'PTX_110', + amount: 3, + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + const result = await service.checkOrderCompletion(order.context, order.correlationId); + + expect(result.isComplete).toBe(true); + expect(result.payoutTxId).toBe('PTX_110'); + expect(result.payoutAmount).toBe(3); + expect(result.payoutAsset).toBe(order.asset); + expect(result.payoutFee).toEqual(order.payoutFee); + }); + + it('reports isComplete=false for an order found in a non-COMPLETE status', async () => { + const order = createCustomPayoutOrder({ id: 111, status: PayoutOrderStatus.PAYOUT_PENDING }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + const result = await service.checkOrderCompletion(order.context, order.correlationId); + + expect(result.isComplete).toBe(false); + }); + + it('returns falsy fields when no matching order exists', async () => { + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(null); + + const result = await service.checkOrderCompletion(PayoutOrderContext.BUY_CRYPTO, 'UNKNOWN'); + + expect(result.isComplete).toBeFalsy(); + expect(result.payoutTxId).toBeFalsy(); + expect(result.payoutFee).toBeFalsy(); + expect(result.payoutAmount).toBeFalsy(); + expect(result.payoutAsset).toBeFalsy(); + }); + }); + + describe('#estimateFee(...) / #estimateBlockchainFee(...)', () => { + let service: PayoutService; + let payoutStrategyRegistry: PayoutStrategyRegistry; + let prepareStrategyRegistry: PrepareStrategyRegistry; + + beforeEach(() => { + payoutStrategyRegistry = mock(); + prepareStrategyRegistry = mock(); + + service = new PayoutService( + mock(), + mock(), + mock(), + mock(), + payoutStrategyRegistry, + prepareStrategyRegistry, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('combines the prepare fee and the payout fee into a single rounded total fee', async () => { + const targetAsset = createDefaultAsset(); + const asset = createDefaultAsset(); + const feeAsset = createDefaultAsset(); + + const prepareEstimateSpy = jest.fn().mockResolvedValue({ asset: feeAsset, amount: 0.1 }); + const payoutEstimateSpy = jest.fn().mockResolvedValue({ asset: feeAsset, amount: 0.2 }); + + jest + .spyOn(prepareStrategyRegistry, 'getPrepareStrategy') + .mockReturnValue({ estimateFee: prepareEstimateSpy } as any); + jest + .spyOn(payoutStrategyRegistry, 'getPayoutStrategy') + .mockReturnValue({ estimateFee: payoutEstimateSpy } as any); + + const result = await service.estimateFee(targetAsset, 'ADDR', 1, asset); + + expect(prepareEstimateSpy).toHaveBeenCalledWith(targetAsset); + expect(payoutEstimateSpy).toHaveBeenCalledWith(targetAsset, 'ADDR', 1, asset); + // amount checked separately with toBeCloseTo: prepareFee (0.1) + payoutFee (0.2) hits a binary + // floating-point artifact (0.30000000000000004) that Util.round(...,16) cannot fully absorb + // (rounding to 16 decimals is at the edge of double precision), so an exact toEqual on the + // whole object would be flaky. toBeCloseTo still proves both fees were summed into ~0.3. + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.3, 8); + }); + + it('delegates blockchain-fee estimation to the payout strategy resolved for the asset', async () => { + const asset = createDefaultAsset(); + const feeResult = { asset, amount: 0.05 }; + const estimateBlockchainFeeSpy = jest.fn().mockResolvedValue(feeResult); + + jest + .spyOn(payoutStrategyRegistry, 'getPayoutStrategy') + .mockReturnValue({ estimateBlockchainFee: estimateBlockchainFeeSpy } as any); + + const result = await service.estimateBlockchainFee(asset); + + expect(estimateBlockchainFeeSpy).toHaveBeenCalledWith(asset); + expect(result).toBe(feeResult); + }); + }); + + describe('#getRecentPayoutSentCorrelationIds(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + + beforeEach(() => { + payoutOrderRepo = mock(); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('queries recently sent/completed orders for the context and returns a deduplicated set of correlationIds', async () => { + const orderA = createCustomPayoutOrder({ id: 150, correlationId: 'CID_A' }); + const orderB = createCustomPayoutOrder({ id: 151, correlationId: 'CID_B' }); + const orderDuplicate = createCustomPayoutOrder({ id: 152, correlationId: 'CID_A' }); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB, orderDuplicate]); + + const result = await service.getRecentPayoutSentCorrelationIds(PayoutOrderContext.BUY_CRYPTO); + + expect(findBySpy).toHaveBeenCalledWith( + expect.objectContaining({ + context: PayoutOrderContext.BUY_CRYPTO, + status: In([PayoutOrderStatus.PAYOUT_PENDING, PayoutOrderStatus.COMPLETE]), + }), + ); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(2); + expect(result).toEqual(new Set(['CID_A', 'CID_B'])); + }); + }); + + describe('#checkPreparationCompletion(...) / #checkPayoutCompletion(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let prepareStrategyRegistry: PrepareStrategyRegistry; + let payoutStrategyRegistry: PayoutStrategyRegistry; + let logs: PayoutLogService; + + beforeEach(() => { + payoutOrderRepo = mock(); + prepareStrategyRegistry = mock(); + payoutStrategyRegistry = mock(); + logs = mock(); + + service = new PayoutService( + logs, + mock(), + payoutOrderRepo, + mock(), + payoutStrategyRegistry, + prepareStrategyRegistry, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('checks preparation completion per strategy group and logs the newly confirmed orders', async () => { + const order = createCustomPayoutOrder({ id: 120, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + const checkSpy = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(prepareStrategyRegistry, 'getPrepareStrategy') + .mockReturnValue({ checkPreparationCompletion: checkSpy } as any); + const logSpy = jest.spyOn(logs, 'logTransferCompletion'); + + await service['checkPreparationCompletion'](); + + expect(checkSpy).toHaveBeenCalledWith([order]); + expect(logSpy).toHaveBeenCalledWith([order]); + }); + + it('isolates a throwing prepare strategy: logs the error and continues without crashing the cron step', async () => { + const order = createCustomPayoutOrder({ id: 121, status: PayoutOrderStatus.PREPARATION_PENDING }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockReturnValue({ + checkPreparationCompletion: jest.fn().mockRejectedValue(new Error('boom')), + } as any); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['checkPreparationCompletion']()).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('isolates a throwing prepare-strategy group from a second, healthy strategy group (cross-group isolation)', async () => { + const assetA = createCustomAsset({ id: 211 }); + const assetB = createCustomAsset({ id: 212 }); + const orderA = createCustomPayoutOrder({ id: 126, status: PayoutOrderStatus.PREPARATION_PENDING, asset: assetA }); + const orderB = createCustomPayoutOrder({ id: 127, status: PayoutOrderStatus.PREPARATION_PENDING, asset: assetB }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB]); + + const throwingCheck = jest.fn().mockRejectedValue(new Error('boom')); + const healthyCheck = jest.fn().mockResolvedValue(undefined); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockImplementation((asset) => { + if (asset === assetA) return { checkPreparationCompletion: throwingCheck } as any; + if (asset === assetB) return { checkPreparationCompletion: healthyCheck } as any; + return undefined; + }); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['checkPreparationCompletion']()).resolves.toBeUndefined(); + + expect(throwingCheck).toHaveBeenCalledWith([orderA]); + // group B's checkPreparationCompletion still runs despite group A's throw: proves catch-isolation across groups + expect(healthyCheck).toHaveBeenCalledWith([orderB]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('checks payout completion per strategy group and logs the newly completed orders', async () => { + const order = createCustomPayoutOrder({ id: 122, status: PayoutOrderStatus.COMPLETE }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + const checkSpy = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(payoutStrategyRegistry, 'getPayoutStrategy') + .mockReturnValue({ checkPayoutCompletionData: checkSpy } as any); + const logSpy = jest.spyOn(logs, 'logPayoutCompletion'); + + await service['checkPayoutCompletion'](); + + expect(checkSpy).toHaveBeenCalledWith([order]); + expect(logSpy).toHaveBeenCalledWith([order]); + }); + + it('isolates a throwing payout strategy: logs the error and continues without crashing the cron step', async () => { + const order = createCustomPayoutOrder({ id: 123, status: PayoutOrderStatus.PAYOUT_PENDING }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockReturnValue({ + checkPayoutCompletionData: jest.fn().mockRejectedValue(new Error('boom')), + } as any); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['checkPayoutCompletion']()).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('isolates a throwing payout-strategy group from a second, healthy strategy group (cross-group isolation)', async () => { + const assetA = createCustomAsset({ id: 221 }); + const assetB = createCustomAsset({ id: 222 }); + const orderA = createCustomPayoutOrder({ id: 128, status: PayoutOrderStatus.PAYOUT_PENDING, asset: assetA }); + const orderB = createCustomPayoutOrder({ id: 129, status: PayoutOrderStatus.PAYOUT_PENDING, asset: assetB }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB]); + + const throwingCheck = jest.fn().mockRejectedValue(new Error('boom')); + const healthyCheck = jest.fn().mockResolvedValue(undefined); + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockImplementation((asset) => { + if (asset === assetA) return { checkPayoutCompletionData: throwingCheck } as any; + if (asset === assetB) return { checkPayoutCompletionData: healthyCheck } as any; + return undefined; + }); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['checkPayoutCompletion']()).resolves.toBeUndefined(); + + expect(throwingCheck).toHaveBeenCalledWith([orderA]); + // group B's checkPayoutCompletionData still runs despite group A's throw: proves catch-isolation across groups + expect(healthyCheck).toHaveBeenCalledWith([orderB]); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('#prepareNewOrders(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let prepareStrategyRegistry: PrepareStrategyRegistry; + let logs: PayoutLogService; + + beforeEach(() => { + payoutOrderRepo = mock(); + prepareStrategyRegistry = mock(); + logs = mock(); + + service = new PayoutService( + logs, + mock(), + payoutOrderRepo, + mock(), + mock(), + prepareStrategyRegistry, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('skips preparing new orders while the latest order is within the debounce window', async () => { + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue({ created: new Date() } as PayoutOrder); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy'); + + await service['prepareNewOrders'](); + + expect(findBySpy).not.toHaveBeenCalled(); + }); + + it('treats an empty order table (no latest order date) as stable and proceeds to prepare', async () => { + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue(null); + const order = createCustomPayoutOrder({ id: 130, status: PayoutOrderStatus.PREPARATION_PENDING }); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + const prepareSpy = jest.fn().mockResolvedValue(undefined); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockReturnValue({ preparePayout: prepareSpy } as any); + const logSpy = jest.spyOn(logs, 'logNewPayoutOrders'); + + await service['prepareNewOrders'](); + + expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.CREATED }); + expect(prepareSpy).toHaveBeenCalledWith([order]); + expect(logSpy).toHaveBeenCalledWith([order]); + }); + + it('once the debounce time has elapsed, prepares CREATED orders per strategy group', async () => { + const oldDate = new Date(Date.now() - 60_000); + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue({ created: oldDate } as PayoutOrder); + const order = createCustomPayoutOrder({ id: 131, status: PayoutOrderStatus.CREATED }); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + const prepareSpy = jest.fn().mockResolvedValue(undefined); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockReturnValue({ preparePayout: prepareSpy } as any); + + await service['prepareNewOrders'](); + + expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.CREATED }); + expect(prepareSpy).toHaveBeenCalledWith([order]); + }); + + it('isolates a throwing prepare strategy during new-order preparation: logs the error and continues', async () => { + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue(null); + const order = createCustomPayoutOrder({ id: 132, status: PayoutOrderStatus.CREATED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockReturnValue({ + preparePayout: jest.fn().mockRejectedValue(new Error('boom')), + } as any); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['prepareNewOrders']()).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('isolates a throwing prepare-strategy group from a second, healthy strategy group during new-order preparation (cross-group isolation)', async () => { + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue(null); + const assetA = createCustomAsset({ id: 231 }); + const assetB = createCustomAsset({ id: 232 }); + const orderA = createCustomPayoutOrder({ id: 133, status: PayoutOrderStatus.CREATED, asset: assetA }); + const orderB = createCustomPayoutOrder({ id: 134, status: PayoutOrderStatus.CREATED, asset: assetB }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB]); + + const throwingPrepare = jest.fn().mockRejectedValue(new Error('boom')); + const healthyPrepare = jest.fn().mockResolvedValue(undefined); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockImplementation((asset) => { + if (asset === assetA) return { preparePayout: throwingPrepare } as any; + if (asset === assetB) return { preparePayout: healthyPrepare } as any; + return undefined; + }); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['prepareNewOrders']()).resolves.toBeUndefined(); + + expect(throwingPrepare).toHaveBeenCalledWith([orderA]); + // group B's preparePayout still runs despite group A's throw: proves catch-isolation across groups + expect(healthyPrepare).toHaveBeenCalledWith([orderB]); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('#groupByStrategies(...)', () => { + let service: PayoutService; + + beforeEach(() => { + service = new PayoutService( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('accumulates multiple orders resolving to the same strategy into a single group (existing-group branch)', () => { + const sharedStrategy = { name: 'shared-strategy' }; + const orderA = createCustomPayoutOrder({ id: 140 }); + const orderB = createCustomPayoutOrder({ id: 141 }); + const getter = jest.fn().mockReturnValue(sharedStrategy); + + const groups = service['groupByStrategies']([orderA, orderB], getter); + + expect(groups.size).toBe(1); + expect(groups.get(sharedStrategy)).toEqual([orderA, orderB]); + }); + + it('skips an order without a resolvable strategy and logs a warning', () => { + const orphanOrder = createCustomPayoutOrder({ id: 142 }); + const getter = jest.fn().mockReturnValue(undefined); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + const groups = service['groupByStrategies']([orphanOrder], getter); + + expect(groups.size).toBe(0); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`for payout order ID ${orphanOrder.id}`)); + }); + }); + + describe('#createMailRequest(...)', () => { + let service: PayoutService; + + beforeEach(() => { + service = new PayoutService( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); + }); + + it('defaults orders to an empty list, yielding an empty correlationId', () => { + const request = service['createMailRequest']('Payout failed'); + + expect(request.correlationId).toBe(''); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/payout-arkade.service.ts b/src/subdomains/supporting/payout/services/payout-arkade.service.ts index 92589cf07a..27c5b08ac3 100644 --- a/src/subdomains/supporting/payout/services/payout-arkade.service.ts +++ b/src/subdomains/supporting/payout/services/payout-arkade.service.ts @@ -1,13 +1,21 @@ import { Injectable } from '@nestjs/common'; import { ArkadeService } from 'src/integration/blockchain/arkade/arkade.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutArkadeService { constructor(private readonly arkadeService: ArkadeService) {} async sendTransaction(address: string, amount: number): Promise { - return this.arkadeService.sendTransaction(address, amount).then((r) => r.txid); + try { + const result = await this.arkadeService.sendTransaction(address, amount); + return result.txid; + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async isHealthy(): Promise { diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts index e962ad03f6..365254a12e 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts @@ -5,7 +5,9 @@ import { BitcoinTestnet4NodeType, BitcoinTestnet4Service, } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { PayoutOrderContext } from '../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; @Injectable() @@ -28,7 +30,13 @@ export class PayoutBitcoinTestnet4Service extends PayoutBitcoinBasedService { async sendUtxoToMany(_context: PayoutOrderContext, payout: PayoutGroup): Promise { const feeRate = await this.getCurrentFeeRate(); - return this.client.sendMany(payout, feeRate); + + try { + return await this.client.sendMany(payout, feeRate); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(_context: PayoutOrderContext, payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts index 28ba7bc8ff..d1929a0708 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts @@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common'; import { BitcoinClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; import { BitcoinNodeType, BitcoinService } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Util } from 'src/shared/utils/util'; import { PayoutOrderContext } from '../entities/payout-order.entity'; import { InvalidPayoutAmountException } from '../exceptions/invalid-payout-amount.exception'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; // Bitcoin Core's send/sendmany RPC parses amount fields with ParseFixedPoint(decimals=8) @@ -38,7 +40,12 @@ export class PayoutBitcoinService extends PayoutBitcoinBasedService { const sanitizedPayout = this.sanitizePayoutAmounts(payout); const feeRate = Util.round(await this.getCurrentFeeRate(), BTC_FEE_RATE_DECIMALS); - return this.client.sendMany(sanitizedPayout, feeRate); + try { + return await this.client.sendMany(sanitizedPayout, feeRate); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(_context: any, payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-cardano.service.ts b/src/subdomains/supporting/payout/services/payout-cardano.service.ts index fe77e6ed4e..27f157e069 100644 --- a/src/subdomains/supporting/payout/services/payout-cardano.service.ts +++ b/src/subdomains/supporting/payout/services/payout-cardano.service.ts @@ -1,17 +1,29 @@ import { Injectable } from '@nestjs/common'; import { CardanoService } from 'src/integration/blockchain/cardano/services/cardano.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutCardanoService { constructor(private readonly cardanoService: CardanoService) {} async sendNativeCoin(address: string, amount: number): Promise { - return this.cardanoService.sendNativeCoinFromDex(address, amount); + try { + return await this.cardanoService.sendNativeCoinFromDex(address, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } - async sendToken(address: string, token: Asset, amount: number) { - return this.cardanoService.sendTokenFromDex(address, token, amount); + async sendToken(address: string, token: Asset, amount: number): Promise { + try { + return await this.cardanoService.sendTokenFromDex(address, token, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-evm.service.ts b/src/subdomains/supporting/payout/services/payout-evm.service.ts index d3a28ea29d..cf3235ab4f 100644 --- a/src/subdomains/supporting/payout/services/payout-evm.service.ts +++ b/src/subdomains/supporting/payout/services/payout-evm.service.ts @@ -1,6 +1,8 @@ +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; import { EvmService } from 'src/integration/blockchain/shared/evm/evm.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutTxStatus } from '../interfaces'; export abstract class PayoutEvmService { @@ -11,11 +13,21 @@ export abstract class PayoutEvmService { } async sendNativeCoin(address: string, amount: number, nonce?: number): Promise { - return this.client.sendNativeCoinFromDex(address, amount, nonce); + try { + return await this.client.sendNativeCoinFromDex(address, amount, nonce); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async sendToken(address: string, tokenName: Asset, amount: number, nonce?: number): Promise { - return this.client.sendTokenFromDex(address, tokenName, amount, nonce); + try { + return await this.client.sendTokenFromDex(address, tokenName, amount, nonce); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string): Promise { diff --git a/src/subdomains/supporting/payout/services/payout-firo.service.ts b/src/subdomains/supporting/payout/services/payout-firo.service.ts index 7f88b2f101..ba0ed01d62 100644 --- a/src/subdomains/supporting/payout/services/payout-firo.service.ts +++ b/src/subdomains/supporting/payout/services/payout-firo.service.ts @@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common'; import { FiroClient } from 'src/integration/blockchain/firo/firo-client'; import { FiroFeeService } from 'src/integration/blockchain/firo/services/firo-fee.service'; import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { PayoutOrderContext } from '../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; @Injectable() @@ -28,12 +30,24 @@ export class PayoutFiroService extends PayoutBitcoinBasedService { async sendUtxoToMany(_context: PayoutOrderContext, payout: PayoutGroup): Promise { const feeRate = await this.getCurrentFeeRate(); - return this.client.sendMany(payout, feeRate); + + try { + return await this.client.sendMany(payout, feeRate); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async mintSpark(payout: PayoutGroup): Promise { const recipients = payout.map((p) => ({ address: p.addressTo, amount: p.amount })); - return this.client.mintSpark(recipients); + + try { + return await this.client.mintSpark(recipients); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(_context: PayoutOrderContext, payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-icp.service.ts b/src/subdomains/supporting/payout/services/payout-icp.service.ts index 7728a4f16a..e170215525 100644 --- a/src/subdomains/supporting/payout/services/payout-icp.service.ts +++ b/src/subdomains/supporting/payout/services/payout-icp.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutInternetComputerService { @@ -12,11 +14,21 @@ export class PayoutInternetComputerService { } async sendNativeCoin(address: string, amount: number): Promise { - return this.client.sendNativeCoinFromDex(address, amount); + try { + return await this.client.sendNativeCoinFromDex(address, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async sendToken(address: string, token: Asset, amount: number): Promise { - return this.client.sendTokenFromDex(address, token, amount); + try { + return await this.client.sendTokenFromDex(address, token, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string, token?: Asset): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-lightning.service.ts b/src/subdomains/supporting/payout/services/payout-lightning.service.ts index 97e3d15cf4..4dd8c896bc 100644 --- a/src/subdomains/supporting/payout/services/payout-lightning.service.ts +++ b/src/subdomains/supporting/payout/services/payout-lightning.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { LightningClient } from 'src/integration/lightning/lightning-client'; -import { LightningHelper } from 'src/integration/lightning/lightning-helper'; +import { LightningAddressType, LightningHelper } from 'src/integration/lightning/lightning-helper'; import { LightningService } from 'src/integration/lightning/services/lightning.service'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutLightningService { @@ -26,7 +28,27 @@ export class PayoutLightningService { } async sendPayment(address: string, amount: number): Promise { - return this.lightningService.sendTransfer(address, amount); + // A keysend (LN_NID) payment carries no invoice payment_hash, so LND cannot deduplicate a + // re-broadcast - a self-healing retry could double-pay. Unlike an invoice payment (LN_URL / + // LND_HUB), we therefore treat every keysend outcome that is not a confirmed send as ambiguous + // (fail-closed): keep the order PAYOUT_DESIGNATED for escalation instead of rolling it back. + const isKeysend = address.startsWith(LightningAddressType.LN_NID); + + try { + const txId = await this.lightningService.sendTransfer(address, amount); + + // An empty payment hash (e.g. a blank base64 hash) is returned without throwing; for a keysend + // it would otherwise roll the order back and re-broadcast, so treat it as a broadcast error. + if (isKeysend && !txId) throw new PayoutBroadcastException('Lightning keysend returned an empty payment hash'); + + return txId; + } catch (e) { + if (e instanceof PayoutBroadcastException) throw e; + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + if (isKeysend) throw new PayoutBroadcastException(e instanceof Error ? e.message : String(e), { cause: e }); + + throw e; + } } async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-monero.service.ts b/src/subdomains/supporting/payout/services/payout-monero.service.ts index 3add184551..44a1692859 100644 --- a/src/subdomains/supporting/payout/services/payout-monero.service.ts +++ b/src/subdomains/supporting/payout/services/payout-monero.service.ts @@ -1,9 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { BaseFeePriority } from 'src/integration/blockchain/monero/dto/monero.dto'; +import { BaseFeePriority, MoneroTransferDto } from 'src/integration/blockchain/monero/dto/monero.dto'; import { MoneroClient } from 'src/integration/blockchain/monero/monero-client'; import { MoneroHelper } from 'src/integration/blockchain/monero/monero-helper'; import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { PayoutOrderContext } from '../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; @Injectable() @@ -25,7 +27,13 @@ export class PayoutMoneroService extends PayoutBitcoinBasedService { } async sendToMany(_context: PayoutOrderContext, payout: PayoutGroup): Promise { - const transfer = await this.client.sendTransfers(payout); + let transfer: MoneroTransferDto; + try { + transfer = await this.client.sendTransfers(payout); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } if (!transfer) { throw new Error(`Error while sending payment by Monero ${payout.map((p) => p.addressTo)}`); diff --git a/src/subdomains/supporting/payout/services/payout-solana.service.ts b/src/subdomains/supporting/payout/services/payout-solana.service.ts index 4d79949b61..df74c47fec 100644 --- a/src/subdomains/supporting/payout/services/payout-solana.service.ts +++ b/src/subdomains/supporting/payout/services/payout-solana.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; import { SolanaClient } from 'src/integration/blockchain/solana/solana-client'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutSolanaService { @@ -12,11 +14,21 @@ export class PayoutSolanaService { } async sendNativeCoin(address: string, amount: number): Promise { - return this.client.sendNativeCoinFromDex(address, amount); + try { + return await this.client.sendNativeCoinFromDex(address, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } - async sendToken(address: string, token: Asset, amount: number) { - return this.client.sendTokenFromDex(address, token, amount); + async sendToken(address: string, token: Asset, amount: number): Promise { + try { + return await this.client.sendTokenFromDex(address, token, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-spark.service.ts b/src/subdomains/supporting/payout/services/payout-spark.service.ts index 05e760157a..467368e2f1 100644 --- a/src/subdomains/supporting/payout/services/payout-spark.service.ts +++ b/src/subdomains/supporting/payout/services/payout-spark.service.ts @@ -1,13 +1,21 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { SparkService } from 'src/integration/blockchain/spark/spark.service'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutSparkService { constructor(private readonly sparkService: SparkService) {} async sendTransaction(address: string, amount: number): Promise { - return this.sparkService.sendTransaction(address, amount).then((r) => r.txid); + try { + const result = await this.sparkService.sendTransaction(address, amount); + return result.txid; + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async isHealthy(): Promise { diff --git a/src/subdomains/supporting/payout/services/payout-tron.service.ts b/src/subdomains/supporting/payout/services/payout-tron.service.ts index b0f93618a0..8c9ac273d5 100644 --- a/src/subdomains/supporting/payout/services/payout-tron.service.ts +++ b/src/subdomains/supporting/payout/services/payout-tron.service.ts @@ -1,17 +1,29 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { TronService } from 'src/integration/blockchain/tron/services/tron.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutTronService { constructor(private readonly tronService: TronService) {} async sendNativeCoin(address: string, amount: number): Promise { - return this.tronService.sendNativeCoinFromDex(address, amount); + try { + return await this.tronService.sendNativeCoinFromDex(address, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } - async sendToken(address: string, token: Asset, amount: number) { - return this.tronService.sendTokenFromDex(address, token, amount); + async sendToken(address: string, token: Asset, amount: number): Promise { + try { + return await this.tronService.sendTokenFromDex(address, token, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-zano.service.ts b/src/subdomains/supporting/payout/services/payout-zano.service.ts index 7f585ffd33..885109798d 100644 --- a/src/subdomains/supporting/payout/services/payout-zano.service.ts +++ b/src/subdomains/supporting/payout/services/payout-zano.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { ZanoService } from 'src/integration/blockchain/zano/services/zano.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; @Injectable() @@ -22,11 +24,21 @@ export class PayoutZanoService extends PayoutBitcoinBasedService { } async sendCoins(payout: PayoutGroup): Promise { - return this.zanoService.sendCoins(payout).then((r) => r.txId); + try { + return await this.zanoService.sendCoins(payout).then((r) => r.txId); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async sendTokens(payout: PayoutGroup, token: Asset): Promise { - return this.zanoService.sendTokens(payout, token).then((r) => r.txId); + try { + return await this.zanoService.sendTokens(payout, token).then((r) => r.txId); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(_context: any, payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout.service.ts b/src/subdomains/supporting/payout/services/payout.service.ts index da3b82e7c1..389bae6207 100644 --- a/src/subdomains/supporting/payout/services/payout.service.ts +++ b/src/subdomains/supporting/payout/services/payout.service.ts @@ -110,8 +110,22 @@ export class PayoutService { const order = await this.payoutOrderRepo.findOneBy({ id }); if (!order) throw new NotFoundException('Payout order not found'); + // Only an already-broadcast order (payoutTxId set) may be sped up. Any other status would + // enter doPayout without a payoutTxId, trip the designate-before-broadcast guard and trigger + // a fresh broadcast — a double-payout risk on an order the operator only meant to accelerate. + if (order.status !== PayoutOrderStatus.PAYOUT_PENDING) + throw new BadRequestException( + `Payout order ${id} cannot be sped up in status ${order.status}, expected ${PayoutOrderStatus.PAYOUT_PENDING}`, + ); + const strategy = this.payoutStrategyRegistry.getPayoutStrategy(order.asset); + // Speedup reuses the pending tx's nonce to replace it; only EVM implements that (and only while + // TX_SPEEDUP is enabled). On any other chain doPayout would broadcast a second, independent + // transaction while the original stays valid — a double payout. Reject rather than risk it. + if (!strategy.supportsSpeedup || DisabledProcess(Process.TX_SPEEDUP)) + throw new BadRequestException(`Payout order ${id} does not support transaction speedup`); + await strategy.doPayout([order]); } diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-bitcoin-based.strategy.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-bitcoin-based.strategy.spec.ts index 0b1ff1e655..360bb91546 100644 --- a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-bitcoin-based.strategy.spec.ts +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-bitcoin-based.strategy.spec.ts @@ -1,17 +1,25 @@ import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; +import * as processServiceModule from 'src/shared/services/process.service'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; import { createCustomPayoutOrder, createDefaultPayoutOrder, } from '../../../entities/__mocks__/payout-order.entity.mock'; -import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../exceptions/payout-broadcast.exception'; import { FeeResult } from '../../../interfaces'; import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; -import { PayoutBitcoinBasedService } from '../../../services/base/payout-bitcoin-based.service'; +import { PayoutBitcoinBasedService, PayoutGroup } from '../../../services/base/payout-bitcoin-based.service'; import { BitcoinBasedStrategy } from '../impl/base/bitcoin-based.strategy'; describe('PayoutBitcoinBasedStrategy', () => { @@ -232,6 +240,321 @@ describe('PayoutBitcoinBasedStrategy', () => { }); }); + describe('#send(...)', () => { + it('completes the payout and resets retry tracking on a successful dispatch', async () => { + const orders = [ + createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null, retryCount: 2 }), + ]; + strategy.dispatchPayoutImpl = () => Promise.resolve('CHAIN_TX_ID'); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(orders[0].payoutTxId).toBe('CHAIN_TX_ID'); + expect(orders[0].retryCount).toBe(0); + }); + + // The structural fix under test: only a PayoutBroadcastException (client reached the actual + // on-chain broadcast call) must stay fail-closed. This replaces the old `e.message.includes + // ('timeout')` string heuristic. + it('keeps the order PAYOUT_DESIGNATED (fail-closed) when dispatchPayout throws a PayoutBroadcastException', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + strategy.dispatchPayoutImpl = () => Promise.reject(new PayoutBroadcastException('node unreachable after send')); + + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).rejects.toBeInstanceOf( + PayoutBroadcastException, + ); + + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + }); + + it('rolls back to PREPARATION_CONFIRMED (self-heals) when dispatchPayout throws a plain Error', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('Invalid amount')); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(orders[0].status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); + + // Regression test for the removed heuristic: a pre-broadcast RPC timeout (e.g. fee + // estimation) is a plain Error and must now self-heal instead of incorrectly staying + // fail-closed just because its message happens to contain the word "timeout". + it('rolls back (self-heals) a pre-broadcast plain Error whose message contains "timeout"', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('RPC call timeout during fee estimation')); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(orders[0].status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); + + it('stays fail-closed for a PayoutBroadcastException even without the word "timeout" in the message', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + strategy.dispatchPayoutImpl = () => Promise.reject(new PayoutBroadcastException('connection reset')); + + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).rejects.toBeInstanceOf( + PayoutBroadcastException, + ); + + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + }); + + it('records the failure via trackPayoutFailure on both pre- and at-or-after-broadcast errors', async () => { + const preBroadcastOrder = createCustomPayoutOrder({ + id: 20, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + const postBroadcastOrder = createCustomPayoutOrder({ + id: 21, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('pre-broadcast failure')); + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [preBroadcastOrder]); + expect(preBroadcastOrder.retryCount).toBe(1); + expect(preBroadcastOrder.lastError).toBe('pre-broadcast failure'); + + strategy.dispatchPayoutImpl = () => Promise.reject(new PayoutBroadcastException('post-broadcast failure')); + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [postBroadcastOrder])).rejects.toBeInstanceOf( + PayoutBroadcastException, + ); + expect(postBroadcastOrder.retryCount).toBe(1); + expect(postBroadcastOrder.lastError).toBe('post-broadcast failure'); + }); + + it('throws (without persisting) when an order already has a payoutTxId and TX_SPEEDUP is enabled', async () => { + // speedup would reuse an existing txId; the Bitcoin path does not implement it, so it must + // fail-fast before touching the repo. DisabledProcess defaults to true (fail-closed) in tests, + // so force it false to make `!DisabledProcess(TX_SPEEDUP)` true and reach the guard. + const disabledSpy = jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + const orders = [ + createCustomPayoutOrder({ id: 40, status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: 'EXISTING_TX' }), + ]; + + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).rejects.toThrowError( + 'Transaction speedup is not implemented for Bitcoin', + ); + expect(repoSaveSpy).not.toHaveBeenCalled(); + + disabledSpy.mockRestore(); + }); + + it('does not trip the speedup guard when TX_SPEEDUP is disabled, even if an order has a payoutTxId', async () => { + // Default test env: DisabledProcess(TX_SPEEDUP) === true, so `!DisabledProcess(...)` is false and + // the guard is skipped; the order is dispatched through the standard path. + const order = createCustomPayoutOrder({ + id: 41, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: 'PTX_EXISTING', + }); + strategy.dispatchPayoutImpl = () => Promise.resolve('CHAIN_TX_ID'); + repoSaveSpy.mockImplementation(async (o: PayoutOrder) => o as PayoutOrder); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('CHAIN_TX_ID'); + }); + + it('sends a non-recoverable error mail when persisting the paid order fails after a successful dispatch', async () => { + const order = createCustomPayoutOrder({ + id: 30, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + strategy.dispatchPayoutImpl = () => Promise.resolve('CHAIN_TX_ID'); + // designatePayout persists PAYOUT_DESIGNATED fine; only the final PAYOUT_PENDING save fails. + repoSaveSpy.mockImplementation(async (o: PayoutOrder) => { + if (o.status === PayoutOrderStatus.PAYOUT_PENDING) throw new Error('db down'); + return o; + }); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error on saving payout payoutTxId to the database'), + expect.any(Error), + ); + expect(sendErrorMailSpy).toHaveBeenCalledTimes(1); + expect(sendErrorMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ErrorMonitoring', + context: 'Payout', + correlationId: 'PayoutOrder&BuyCrypto&30', + input: expect.objectContaining({ subject: 'Payout Error', isLiqMail: true }), + options: { suppressRecurring: true }, + }), + ); + }); + }); + + describe('#estimateBlockchainFee(...)', () => { + it('delegates to estimateFee(...) with the given asset', async () => { + const asset = createCustomAsset({ id: 7 }); + const feeResult: FeeResult = { asset: createDefaultAsset(), amount: 0.5 }; + strategy.estimateFeeImpl = () => Promise.resolve(feeResult); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toBe(feeResult); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + }); + + describe('#doPayout(...)', () => { + it('groups orders by context and dispatches each healthy context to doPayoutForContext(...)', async () => { + const buyCryptoOrders = [ + createCustomPayoutOrder({ id: 1, context: PayoutOrderContext.BUY_CRYPTO }), + createCustomPayoutOrder({ id: 2, context: PayoutOrderContext.BUY_CRYPTO }), + ]; + const manualOrder = createCustomPayoutOrder({ id: 3, context: PayoutOrderContext.MANUAL }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + const doPayoutForContextSpy = jest.spyOn(strategy, 'doPayoutForContextImpl'); + + await strategy.doPayout([...buyCryptoOrders, manualOrder]); + + expect(bitcoinService.isHealthy).toHaveBeenCalledWith(PayoutOrderContext.BUY_CRYPTO); + expect(bitcoinService.isHealthy).toHaveBeenCalledWith(PayoutOrderContext.MANUAL); + expect(doPayoutForContextSpy).toHaveBeenCalledTimes(2); + expect(doPayoutForContextSpy).toHaveBeenCalledWith(PayoutOrderContext.BUY_CRYPTO, buyCryptoOrders); + expect(doPayoutForContextSpy).toHaveBeenCalledWith(PayoutOrderContext.MANUAL, [manualOrder]); + }); + + it('skips a context whose service is unhealthy', async () => { + const orders = [createCustomPayoutOrder({ id: 1, context: PayoutOrderContext.BUY_CRYPTO })]; + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(false); + const doPayoutForContextSpy = jest.spyOn(strategy, 'doPayoutForContextImpl'); + + await strategy.doPayout(orders); + + expect(doPayoutForContextSpy).not.toHaveBeenCalled(); + }); + + it('catches and logs errors without throwing', async () => { + const orders = [createCustomPayoutOrder({ id: 1, context: PayoutOrderContext.BUY_CRYPTO })]; + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + strategy.doPayoutForContextImpl = () => Promise.reject(new Error('context boom')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await expect(strategy.doPayout(orders)).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error while executing Bitcoin payout orders:', expect.any(Error)); + }); + }); + + describe('#checkPayoutCompletionData(...)', () => { + let pricingService: PricingService; + let convertFn: jest.Mock; + + beforeEach(() => { + new ConfigService(); // sets the module-level Config (Config.defaultVolumeDecimal used by recordPayoutFee) + pricingService = mock(); + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + convertFn = jest.fn().mockReturnValue(0.99); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + repoSaveSpy.mockImplementation(async (o: PayoutOrder) => o as PayoutOrder); + }); + + it('completes each order, records the proportional payout fee and persists once when the tx is complete', async () => { + const feeAsset = createCustomAsset({ name: 'BTC' }); + strategy.feeAssetValue = feeAsset; + const order1 = createCustomPayoutOrder({ + id: 1, + context: PayoutOrderContext.BUY_CRYPTO, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'BTC_TX', + amount: 1, + }); + const order2 = createCustomPayoutOrder({ + id: 2, + context: PayoutOrderContext.BUY_CRYPTO, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'BTC_TX', + amount: 3, + }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(bitcoinService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0008]); + const complete1Spy = jest.spyOn(order1, 'complete'); + const recordFee1Spy = jest.spyOn(order1, 'recordPayoutFee'); + const recordFee2Spy = jest.spyOn(order2, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order1, order2]); + + expect(bitcoinService.getPayoutCompletionData).toHaveBeenCalledWith(PayoutOrderContext.BUY_CRYPTO, 'BTC_TX'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(complete1Spy).toHaveBeenCalledTimes(1); + // proportional fee: totalFee 0.0008 over total amount 4 -> 0.0002 (amount 1) and 0.0006 (amount 3) + expect(convertFn).toHaveBeenCalledWith(0.0002, Config.defaultVolumeDecimal); + expect(convertFn).toHaveBeenCalledWith(0.0006, Config.defaultVolumeDecimal); + expect(recordFee1Spy).toHaveBeenCalledWith(feeAsset, 0.0002, 0.99); + expect(recordFee2Spy).toHaveBeenCalledWith(feeAsset, 0.0006, 0.99); + expect(order1.status).toBe(PayoutOrderStatus.COMPLETE); + expect(order2.status).toBe(PayoutOrderStatus.COMPLETE); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); + expect(repoSaveSpy).toHaveBeenCalledWith(order1); + expect(repoSaveSpy).toHaveBeenCalledWith(order2); + }); + + it('leaves the order untouched when the tx is not yet complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'BTC_TX_PENDING' }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(bitcoinService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('skips a context whose service is unhealthy', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'BTC_TX_SKIP' }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(false); + + await strategy.checkPayoutCompletionData([order]); + + expect(bitcoinService.getPayoutCompletionData).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows a per-tx error from getPayoutCompletionData and continues without persisting', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'BTC_TX_ERR' }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(bitcoinService, 'getPayoutCompletionData').mockRejectedValue(new Error('rpc down')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error while checking payout completion data of payout orders'), + expect.any(Error), + ); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows an error thrown while health-checking and logs it', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'BTC_TX_HEALTH' }); + jest.spyOn(bitcoinService, 'isHealthy').mockRejectedValue(new Error('health boom')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Error while checking payout completion of Bitcoin payout orders:', + expect.any(Error), + ); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + describe('#trackPayoutFailure(...)', () => { it('increments retryCount, persists lastError and lastAttemptDate on every order', async () => { const orders = [createCustomPayoutOrder({ id: 10 }), createCustomPayoutOrder({ id: 11 })]; @@ -313,6 +636,16 @@ describe('PayoutBitcoinBasedStrategy', () => { expect(sendErrorMailSpy).not.toBeCalled(); }); + it('falls back to "Unknown payout error" when the error has no message', async () => { + const orders = [createCustomPayoutOrder({ id: 10 })]; + + await strategy.trackPayoutFailureWrapper(orders, null as unknown as Error); + + expect(orders[0].retryCount).toBe(1); + expect(orders[0].lastError).toBe('Unknown payout error'); + expect(repoSaveSpy).toBeCalledTimes(1); + }); + it('truncates very long error messages to 2048 chars', async () => { const longError = 'X'.repeat(5000); const orders = [createCustomPayoutOrder({ id: 10 })]; @@ -385,6 +718,9 @@ class PayoutBitcoinBasedStrategyWrapper extends BitcoinBasedStrategy { super(notificationService, payoutOrderRepo, bitcoinService); } + // Set per-test so #checkPayoutCompletionData(...) / #estimateFee(...) resolve a known fee asset. + feeAssetValue: Asset = createDefaultAsset(); + get blockchain(): Blockchain { return Blockchain.BITCOIN; } @@ -393,12 +729,26 @@ class PayoutBitcoinBasedStrategyWrapper extends BitcoinBasedStrategy { return AssetType.COIN; } - protected doPayoutForContext(): Promise { - throw new Error('Method not implemented.'); + // Overridable per-test so #doPayout(...) tests can assert delegation / simulate a failing context + // without a real chain client; spied on to capture (context, group) arguments. + doPayoutForContextImpl: (context: PayoutOrderContext, group: PayoutOrder[]) => Promise = () => + Promise.resolve(); + + protected doPayoutForContext(context: PayoutOrderContext, group: PayoutOrder[]): Promise { + return this.doPayoutForContextImpl(context, group); } - protected async dispatchPayout(): Promise { - return 'TX_ID_01'; + // Overridable per-test so #send(...) tests can simulate pre-broadcast vs. at-or-after-broadcast + // failures without needing a real chain client. + dispatchPayoutImpl: (context: PayoutOrderContext, payout: PayoutGroup, token?: Asset) => Promise = () => + Promise.resolve('TX_ID_01'); + + protected async dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup, token?: Asset): Promise { + return this.dispatchPayoutImpl(context, payout, token); + } + + sendWrapper(context: PayoutOrderContext, orders: PayoutOrder[]) { + return this.send(context, orders); } createPayoutGroupsWrapper(orders: PayoutOrder[], maxGroupSize: number) { @@ -425,11 +775,15 @@ class PayoutBitcoinBasedStrategyWrapper extends BitcoinBasedStrategy { return this.trackPayoutFailure(orders, error); } - estimateFee(): Promise { - throw new Error('Method not implemented.'); + // Overridable per-test; #estimateBlockchainFee(...) delegates to this via estimateFee(...). + estimateFeeImpl: (asset: Asset) => Promise = () => + Promise.resolve({ asset: this.feeAssetValue, amount: 0 }); + + estimateFee(asset: Asset): Promise { + return this.estimateFeeImpl(asset); } protected getFeeAsset(): Promise { - throw new Error('Method not implemented.'); + return Promise.resolve(this.feeAssetValue); } } diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts new file mode 100644 index 0000000000..d5ae58091b --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts @@ -0,0 +1,334 @@ +import { mock } from 'jest-mock-extended'; +import { ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../exceptions/payout-broadcast.exception'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutArkadeService } from '../../../services/payout-arkade.service'; +import { PayoutCardanoService } from '../../../services/payout-cardano.service'; +import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { PayoutLightningService } from '../../../services/payout-lightning.service'; +import { PayoutSolanaService } from '../../../services/payout-solana.service'; +import { PayoutSparkService } from '../../../services/payout-spark.service'; +import { PayoutTronService } from '../../../services/payout-tron.service'; +import { ArkadeStrategy } from '../impl/arkade.strategy'; +import { EvmStrategy } from '../impl/base/evm.strategy'; +import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; +import { InternetComputerCoinStrategy } from '../impl/icp-coin.strategy'; +import { LightningStrategy } from '../impl/lightning.strategy'; +import { SolanaCoinStrategy } from '../impl/solana-coin.strategy'; +import { SparkStrategy } from '../impl/spark.strategy'; +import { TronCoinStrategy } from '../impl/tron-coin.strategy'; + +// Fixture returned by every per-strategy setup function below: a strategy instance whose +// broadcast sink (dispatchPayout/sendTransaction/sendPayment) and persistence sink +// (payoutOrderRepo.save) are individually spy-able, so the shared variant suite can drive and +// assert designate-before-broadcast behavior identically across all eight strategies. +interface DesignateBeforeBroadcastFixture { + doPayout: (orders: PayoutOrder[]) => Promise; + dispatchSpy: jest.SpyInstance; + repoSaveSpy: jest.SpyInstance; +} + +interface DesignateBeforeBroadcastOptions { + // Error thrown by dispatchPayout to simulate a broadcast-time failure. Plain Error by default + // (matches every strategy that still has the old, undifferentiated fail-closed catch). EVM, + // Cardano, Solana, ICP, Lightning, Tron, Arkade and Spark now map a real send failure to + // PayoutBroadcastException (see payout-evm/cardano/solana/icp/lightning/tron/arkade/spark.service.ts + // + evm/cardano/solana/icp/tron/arkade/spark-client.ts and lightning-client.ts), so their fixtures + // must throw that type here to stay representative. + broadcastError?: Error; + // PayoutStrategy#handleBroadcastError (see payout.strategy.ts, exercised via EvmStrategy / + // CardanoStrategy / SolanaStrategy #doPayout) treats any non-PayoutBroadcastException + // before/at the pre-broadcast designate-save as provably pre-broadcast and rolls back for + // retry - including a failed designate save itself, since dispatchPayout was never reached + // either way. Every other (not-yet-migrated) strategy keeps the old "never touch it again" + // behavior for that case. + designateSaveFailureRollsBack?: boolean; +} + +// Shared table of the four designate-before-broadcast variants, run identically against every +// strategy's fixture. Kept as real, individually-named it() blocks (not a silent for-loop) so a +// failure in one strategy/variant combination is reported on its own line. +function runDesignateBeforeBroadcastSuite( + strategyName: string, + setup: () => DesignateBeforeBroadcastFixture, + options: DesignateBeforeBroadcastOptions = {}, +): void { + const { broadcastError = new Error('broadcast failed'), designateSaveFailureRollsBack = false } = options; + + describe(`${strategyName} #doPayout(...)`, () => { + beforeAll(() => { + new ConfigService(); // sets module-level Config (Config.payout.maxPreBroadcastRetries used by handleBroadcastError) + }); + + it('designates and persists BEFORE broadcasting, then reaches PAYOUT_PENDING with the new txId', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + + // capture the persisted status at the moment of the first (pre-broadcast) save + let statusAtFirstSave: PayoutOrderStatus | undefined; + repoSaveSpy.mockImplementationOnce(async (o: PayoutOrder) => { + statusAtFirstSave = o.status; + return o; + }); + dispatchSpy.mockResolvedValue('TX_NEW'); + + await doPayout([order]); + + expect(statusAtFirstSave).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_NEW'); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); // designate + pending + }); + + it('leaves the order PAYOUT_DESIGNATED (no txId, no rollback) when the broadcast throws', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchSpy.mockRejectedValue(broadcastError); + + await doPayout([order]); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBeNull(); + expect(dispatchSpy).toHaveBeenCalledTimes(1); // no second broadcast + expect(rollbackSpy).not.toHaveBeenCalled(); // fail-closed: never auto-rollback after broadcast + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the pre-broadcast designate save + }); + + it('does NOT re-designate when payoutTxId is already set (speedup/expired-retry path)', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'OLD_TX' }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + dispatchSpy.mockResolvedValue('NEW_TX'); + + await doPayout([order]); + + expect(designateSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('NEW_TX'); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the pending save, no extra designate save + }); + + it('never broadcasts when the pre-broadcast designate save throws', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + repoSaveSpy.mockRejectedValueOnce(new Error('DB unavailable')); + + // doPayout must swallow the error (fail-closed logging), never let it escape + await expect(doPayout([order])).resolves.toBeUndefined(); + + expect(dispatchSpy).not.toHaveBeenCalled(); // broadcast is never reached + + if (designateSaveFailureRollsBack) { + // EVM: the failed designate save is itself proof dispatchPayout was never reached, so the + // differentiated catch rolls back for retry (failed save + rollback save = 2 total). + expect(repoSaveSpy).toHaveBeenCalledTimes(2); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.payoutTxId).toBeNull(); + } else { + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + // order.designatePayout() already ran synchronously before the rejected save, so the + // in-memory object is PAYOUT_DESIGNATED even though nothing was ever persisted - it never + // advances to PAYOUT_PENDING/txId, which is what matters for not being double-broadcast. + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBeNull(); + } + }); + }); +} + +function repoSaveEcho(payoutOrderRepo: PayoutOrderRepository): jest.SpyInstance { + return jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); +} + +function setupEvm(): DesignateBeforeBroadcastFixture { + const payoutEvmService = mock(); + const payoutOrderRepo = mock(); + const dispatchFn = jest.fn(); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, dispatchFn); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy: dispatchFn, repoSaveSpy }; +} + +function setupSolana(): DesignateBeforeBroadcastFixture { + const solanaService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const dispatchSpy = jest.spyOn(solanaService, 'sendNativeCoin'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new SolanaCoinStrategy(solanaService, assetService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupTron(): DesignateBeforeBroadcastFixture { + const tronService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const dispatchSpy = jest.spyOn(tronService, 'sendNativeCoin'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new TronCoinStrategy(tronService, assetService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupCardano(): DesignateBeforeBroadcastFixture { + const cardanoService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const dispatchSpy = jest.spyOn(cardanoService, 'sendNativeCoin'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new CardanoCoinStrategy(cardanoService, assetService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupIcp(): DesignateBeforeBroadcastFixture { + const internetComputerService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const dispatchSpy = jest.spyOn(internetComputerService, 'sendNativeCoin'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new InternetComputerCoinStrategy(internetComputerService, assetService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupArkade(): DesignateBeforeBroadcastFixture { + const arkadeService = mock(); + const payoutOrderRepo = mock(); + const assetService = mock(); + const dispatchSpy = jest.spyOn(arkadeService, 'sendTransaction'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new ArkadeStrategy(arkadeService, payoutOrderRepo, assetService); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupSpark(): DesignateBeforeBroadcastFixture { + const sparkService = mock(); + const payoutOrderRepo = mock(); + const assetService = mock(); + const dispatchSpy = jest.spyOn(sparkService, 'sendTransaction'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new SparkStrategy(sparkService, payoutOrderRepo, assetService); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupLightning(): DesignateBeforeBroadcastFixture { + const assetService = mock(); + const payoutLightningService = mock(); + const payoutOrderRepo = mock(); + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); + const dispatchSpy = jest.spyOn(payoutLightningService, 'sendPayment'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new LightningStrategy(assetService, payoutLightningService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +describe('Payout designate-before-broadcast', () => { + runDesignateBeforeBroadcastSuite('EvmStrategy', setupEvm, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('SolanaStrategy (SolanaCoinStrategy)', setupSolana, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('TronStrategy (TronCoinStrategy)', setupTron, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('CardanoStrategy (CardanoCoinStrategy)', setupCardano, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('IcpStrategy (InternetComputerCoinStrategy)', setupIcp, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('ArkadeStrategy', setupArkade, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('SparkStrategy', setupSpark, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('LightningStrategy', setupLightning, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + + describe('LightningStrategy #doPayout(...) — health gate', () => { + it('designates nothing, broadcasts nothing and saves nothing when isHealthy() is false', async () => { + const assetService = mock(); + const payoutLightningService = mock(); + const payoutOrderRepo = mock(); + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(false); + const sendPaymentSpy = jest.spyOn(payoutLightningService, 'sendPayment'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new LightningStrategy(assetService, payoutLightningService, payoutOrderRepo); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + + await strategy.doPayout([order]); + + expect(designateSpy).not.toHaveBeenCalled(); + expect(sendPaymentSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.payoutTxId).toBeNull(); + }); + }); +}); + +class EvmStrategyWrapper extends EvmStrategy { + constructor( + payoutEvmService: PayoutEvmService, + payoutOrderRepo: PayoutOrderRepository, + private readonly dispatchFn: jest.Mock, + ) { + super(payoutEvmService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected dispatchPayout(order: PayoutOrder): Promise { + return this.dispatchFn(order); + } + + protected getCurrentGasForTransaction(): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-retry.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-retry.spec.ts new file mode 100644 index 0000000000..a7c93c5dc2 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-retry.spec.ts @@ -0,0 +1,346 @@ +import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import * as processServiceModule from 'src/shared/services/process.service'; +import { PayoutTxStatus } from 'src/subdomains/supporting/payout/interfaces'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../exceptions/payout-broadcast.exception'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { EvmStrategy } from '../impl/base/evm.strategy'; + +describe('Payout EVM retry x designate-before-broadcast guard', () => { + describe('EvmStrategy #checkPayoutCompletionData(...)', () => { + let strategy: EvmStrategyWrapper; + let payoutEvmService: PayoutEvmService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let dispatchFn: jest.Mock; + let repoSaveSpy: jest.SpyInstance; + let convertFn: jest.Mock; + + beforeEach(() => { + new ConfigService(); // sets the module-level Config (Config.defaultVolumeDecimal used by recordPayoutFee) + payoutEvmService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + dispatchFn = jest.fn(); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + + // TX_SPEEDUP is fail-closed (disabled) by default in tests; enable it so nonce reuse/fresh-nonce + // behaviour of getOrderNonce(...) is actually observable. + jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + + strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, dispatchFn); + // pricingService is an @Inject() readonly property on PayoutStrategy; there is no DI in the + // unit test, so define it directly on the instance. + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + convertFn = jest.fn().mockReturnValue(42); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('(a) complete: records the payout fee, completes the order and persists once', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OK' }); + const status: PayoutTxStatus = { state: 'complete', fee: 0.001 }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutEvmService.getPayoutCompletionData).toHaveBeenCalledWith('TX_OK'); + expect(pricingService.getPrice).toHaveBeenCalledWith(expect.any(Asset), PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.001, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledTimes(1); + expect(recordFeeSpy).toHaveBeenCalledWith(expect.any(Asset), 0.001, 42); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(designateSpy).not.toHaveBeenCalled(); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('(b) failed, not out-of-gas (on-chain revert): designates for investigation, never retries', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_REVERT' }); + const status: PayoutTxStatus = { state: 'failed', isOutOfGas: false }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + const completeSpy = jest.spyOn(order, 'complete'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + + await strategy.checkPayoutCompletionData([order]); + + expect(designateSpy).toHaveBeenCalledTimes(1); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBe('TX_REVERT'); // txId kept for investigation, not cleared + expect(completeSpy).not.toHaveBeenCalled(); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('(c) failed, out-of-gas + retryable: rolls back, re-designates and dispatches with a fresh nonce', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OOG' }); + const status: PayoutTxStatus = { state: 'failed', isOutOfGas: true }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const completeSpy = jest.spyOn(order, 'complete'); + dispatchFn.mockResolvedValue('TX_NEW_OOG'); + + await strategy.checkPayoutCompletionData([order]); + + // OOG frees the spent nonce: rollback clears payoutTxId, so the guard sees a fresh order and re-designates. + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(designateSpy).toHaveBeenCalledTimes(1); + expect(completeSpy).not.toHaveBeenCalled(); + expect(dispatchFn).toHaveBeenCalledTimes(1); + expect(dispatchFn).toHaveBeenCalledWith(order, undefined); // fresh (unset) nonce, no reuse + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); // fresh nonce, no reuse + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_NEW_OOG'); + expect(repoSaveSpy).toHaveBeenCalledTimes(3); // rollback save + designate save + pending save + }); + + it('(d) expired in mempool + retryable: keeps payoutTxId, skips re-designation and reuses the nonce', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OLD' }); + order.updated = new Date(Date.now() - 2 * 60 * 60 * 1000); // > 1h ago, so the retry cooldown has elapsed + const status: PayoutTxStatus = { state: 'pending' }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + jest.spyOn(payoutEvmService, 'isTxExpired').mockResolvedValue(true); + jest.spyOn(payoutEvmService, 'getTxNonce').mockResolvedValue(7); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const completeSpy = jest.spyOn(order, 'complete'); + dispatchFn.mockResolvedValue('TX_NEW_EXPIRED'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutEvmService.isTxExpired).toHaveBeenCalledWith('TX_OLD'); + // Guard: payoutTxId was never cleared, so designateBeforeBroadcast skips re-designation. + expect(designateSpy).not.toHaveBeenCalled(); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutEvmService.getTxNonce).toHaveBeenCalledWith('TX_OLD'); // nonce reuse + expect(dispatchFn).toHaveBeenCalledTimes(1); + expect(dispatchFn).toHaveBeenCalledWith(order, 7); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_NEW_EXPIRED'); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the final pending save, no pre-broadcast designate save + }); + + it('(e) pending: no status change, no completion, no designation, no rollback, no dispatch', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_PENDING' }); + order.updated = new Date(); // < 1h ago: retry cooldown has not elapsed + const status: PayoutTxStatus = { state: 'pending' }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + const completeSpy = jest.spyOn(order, 'complete'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(designateSpy).not.toHaveBeenCalled(); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(payoutEvmService.isTxExpired).not.toHaveBeenCalled(); // cooldown short-circuits before the chain check + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_PENDING'); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + + describe('EvmStrategy #canRetryFailedPayout(...)', () => { + let strategy: EvmStrategyWrapper; + + beforeEach(() => { + const payoutEvmService = mock(); + const payoutOrderRepo = mock(); + strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, jest.fn()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns false when the order has no payoutTxId (nothing broadcast yet, nothing to retry)', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: null }); + + await expect(strategy.canRetryFailedPayout(order)).resolves.toBe(false); + }); + }); + + describe('EvmStrategy #doPayout(...) — pre-broadcast vs. broadcast-boundary catch', () => { + let strategy: EvmStrategyWrapper; + let payoutOrderRepo: PayoutOrderRepository; + let dispatchFn: jest.Mock; + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + new ConfigService(); // sets Config.payout.maxPreBroadcastRetries (default 3) + const payoutEvmService = mock(); + payoutOrderRepo = mock(); + dispatchFn = jest.fn(); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + + strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, dispatchFn); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('pre-broadcast error on first attempt: rolls back to PREPARATION_CONFIRMED, tracks the failure, no second broadcast', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchFn.mockRejectedValue(new Error('nonce could not be fetched')); + + await strategy.doPayout([order]); + + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.retryCount).toBe(1); + expect(order.lastError).toBe('nonce could not be fetched'); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(dispatchFn).toHaveBeenCalledTimes(1); // no second broadcast in the same run + expect(repoSaveSpy).toHaveBeenCalledTimes(2); // pre-broadcast designate save + rollback save + }); + + it('broadcast-boundary error (PayoutBroadcastException): stays PAYOUT_DESIGNATED, no rollback, no retryCount increment', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchFn.mockRejectedValue(new PayoutBroadcastException('tx may already be in-flight')); + + await strategy.doPayout([order]); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.retryCount).toBe(0); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the pre-broadcast designate save + }); + + it('re-entry (payoutTxId already set) + plain error: never rolls back, preserving nonce reuse', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_DESIGNATED, + payoutTxId: 'OLD_TX', + retryCount: 0, + }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + dispatchFn.mockRejectedValue(new Error('replacement transaction underpriced')); + + await strategy.doPayout([order]); + + expect(designateSpy).not.toHaveBeenCalled(); // re-entry: designateBeforeBroadcast is a no-op + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBe('OLD_TX'); + expect(order.retryCount).toBe(0); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('pre-broadcast error that is not an Error instance: records String(e) as the failure message', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + const recordFailureSpy = jest.spyOn(order, 'recordPayoutFailure'); + dispatchFn.mockRejectedValue('rpc failure string'); // rejection with a non-Error value + + await strategy.doPayout([order]); + + expect(recordFailureSpy).toHaveBeenCalledWith('rpc failure string'); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.retryCount).toBe(1); + expect(order.lastError).toBe('rpc failure string'); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); // pre-broadcast designate save + rollback save + }); + + it('pre-broadcast error at the retry cap: stops rolling back so the order escalates to PAYOUT_UNCERTAIN', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + retryCount: Config.payout.maxPreBroadcastRetries, + }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchFn.mockRejectedValue(new Error('gas estimation failed')); + + await strategy.doPayout([order]); + + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); // left for processFailedOrders -> PAYOUT_UNCERTAIN + expect(order.retryCount).toBe(Config.payout.maxPreBroadcastRetries); // not incremented further + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the pre-broadcast designate save + }); + + it('successful payout resets a previously tracked retry count', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + retryCount: 2, + }); + order.lastError = 'gas estimation failed'; + const resetSpy = jest.spyOn(order, 'resetPayoutRetry'); + dispatchFn.mockResolvedValue('TX_OK'); + + await strategy.doPayout([order]); + + expect(resetSpy).toHaveBeenCalledTimes(1); + expect(order.retryCount).toBe(0); + expect(order.lastError).toBeNull(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_OK'); + }); + }); +}); + +class EvmStrategyWrapper extends EvmStrategy { + constructor( + payoutEvmService: PayoutEvmService, + payoutOrderRepo: PayoutOrderRepository, + private readonly dispatchFn: jest.Mock, + ) { + super(payoutEvmService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(order: PayoutOrder): Promise { + // Mirrors the real EVM subclasses (e.g. EthereumCoinStrategy#dispatchPayout), which fetch the + // order nonce right before broadcasting - this is the interaction under test. + const nonce = await this.getOrderNonce(order); + + return this.dispatchFn(order, nonce); + } + + protected getCurrentGasForTransaction(): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(createDefaultAsset()); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-strategies.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-strategies.spec.ts new file mode 100644 index 0000000000..e37ca6cfba --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-strategies.spec.ts @@ -0,0 +1,295 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { ArbitrumCoinStrategy } from '../impl/arbitrum-coin.strategy'; +import { ArbitrumTokenStrategy } from '../impl/arbitrum-token.strategy'; +import { EvmStrategy } from '../impl/base/evm.strategy'; +import { BaseCoinStrategy } from '../impl/base-coin.strategy'; +import { BaseTokenStrategy } from '../impl/base-token.strategy'; +import { BscCoinStrategy } from '../impl/bsc-coin.strategy'; +import { BscTokenStrategy } from '../impl/bsc-token.strategy'; +import { CitreaCoinStrategy } from '../impl/citrea-coin.strategy'; +import { CitreaTokenStrategy } from '../impl/citrea-token.strategy'; +import { EthereumCoinStrategy } from '../impl/ethereum-coin.strategy'; +import { EthereumTokenStrategy } from '../impl/ethereum-token.strategy'; +import { GnosisCoinStrategy } from '../impl/gnosis-coin.strategy'; +import { GnosisTokenStrategy } from '../impl/gnosis-token.strategy'; +import { OptimismCoinStrategy } from '../impl/optimism-coin.strategy'; +import { OptimismTokenStrategy } from '../impl/optimism-token.strategy'; +import { PolygonCoinStrategy } from '../impl/polygon-coin.strategy'; +import { PolygonTokenStrategy } from '../impl/polygon-token.strategy'; +import { SepoliaCoinStrategy } from '../impl/sepolia-coin.strategy'; +import { SepoliaTokenStrategy } from '../impl/sepolia-token.strategy'; + +// The EVM payout services (PayoutArbitrumService, PayoutBscService, ...) differ by static type but +// expose the same broadcast/gas surface used by the strategies; a single generic jest mock covers +// all of them (cast to `any` at the constructor since the concrete type varies per strategy). +interface EvmServiceMock { + sendNativeCoin: jest.Mock; + sendToken: jest.Mock; + getCurrentGasForCoinTransaction: jest.Mock; + getCurrentGasForTokenTransaction: jest.Mock; +} + +function createEvmServiceMock(): EvmServiceMock { + return { + sendNativeCoin: jest.fn(), + sendToken: jest.fn(), + getCurrentGasForCoinTransaction: jest.fn(), + getCurrentGasForTokenTransaction: jest.fn(), + }; +} + +type EvmStrategyCtor = new ( + service: any, + assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, +) => EvmStrategy; + +// Getters that resolve the fee asset on AssetService (assetService.getXCoin()). +type FeeAssetGetter = + | 'getArbitrumCoin' + | 'getBaseCoin' + | 'getBnbCoin' + | 'getCitreaCoin' + | 'getEthCoin' + | 'getGnosisCoin' + | 'getOptimismCoin' + | 'getPolygonCoin' + | 'getSepoliaCoin'; + +interface EvmStrategyCase { + name: string; + Strategy: EvmStrategyCtor; + blockchain: Blockchain; + assetType: AssetType; + isToken: boolean; + feeAssetGetter: FeeAssetGetter; +} + +const cases: EvmStrategyCase[] = [ + { + name: 'ArbitrumCoinStrategy', + Strategy: ArbitrumCoinStrategy, + blockchain: Blockchain.ARBITRUM, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getArbitrumCoin', + }, + { + name: 'ArbitrumTokenStrategy', + Strategy: ArbitrumTokenStrategy, + blockchain: Blockchain.ARBITRUM, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getArbitrumCoin', + }, + { + name: 'BaseCoinStrategy', + Strategy: BaseCoinStrategy, + blockchain: Blockchain.BASE, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getBaseCoin', + }, + { + name: 'BaseTokenStrategy', + Strategy: BaseTokenStrategy, + blockchain: Blockchain.BASE, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getBaseCoin', + }, + { + name: 'BscCoinStrategy', + Strategy: BscCoinStrategy, + blockchain: Blockchain.BINANCE_SMART_CHAIN, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getBnbCoin', + }, + { + name: 'BscTokenStrategy', + Strategy: BscTokenStrategy, + blockchain: Blockchain.BINANCE_SMART_CHAIN, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getBnbCoin', + }, + { + name: 'CitreaCoinStrategy', + Strategy: CitreaCoinStrategy, + blockchain: Blockchain.CITREA, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getCitreaCoin', + }, + { + name: 'CitreaTokenStrategy', + Strategy: CitreaTokenStrategy, + blockchain: Blockchain.CITREA, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getCitreaCoin', + }, + { + name: 'EthereumCoinStrategy', + Strategy: EthereumCoinStrategy, + blockchain: Blockchain.ETHEREUM, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getEthCoin', + }, + { + name: 'EthereumTokenStrategy', + Strategy: EthereumTokenStrategy, + blockchain: Blockchain.ETHEREUM, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getEthCoin', + }, + { + name: 'GnosisCoinStrategy', + Strategy: GnosisCoinStrategy, + blockchain: Blockchain.GNOSIS, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getGnosisCoin', + }, + { + name: 'GnosisTokenStrategy', + Strategy: GnosisTokenStrategy, + blockchain: Blockchain.GNOSIS, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getGnosisCoin', + }, + { + name: 'OptimismCoinStrategy', + Strategy: OptimismCoinStrategy, + blockchain: Blockchain.OPTIMISM, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getOptimismCoin', + }, + { + name: 'OptimismTokenStrategy', + Strategy: OptimismTokenStrategy, + blockchain: Blockchain.OPTIMISM, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getOptimismCoin', + }, + { + name: 'PolygonCoinStrategy', + Strategy: PolygonCoinStrategy, + blockchain: Blockchain.POLYGON, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getPolygonCoin', + }, + { + name: 'PolygonTokenStrategy', + Strategy: PolygonTokenStrategy, + blockchain: Blockchain.POLYGON, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getPolygonCoin', + }, + { + name: 'SepoliaCoinStrategy', + Strategy: SepoliaCoinStrategy, + blockchain: Blockchain.SEPOLIA, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getSepoliaCoin', + }, + { + name: 'SepoliaTokenStrategy', + Strategy: SepoliaTokenStrategy, + blockchain: Blockchain.SEPOLIA, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getSepoliaCoin', + }, +]; + +describe('Payout EVM strategies (table-driven)', () => { + describe.each(cases)('$name', ({ Strategy, blockchain, assetType, isToken, feeAssetGetter }) => { + let service: EvmServiceMock; + let assetService: AssetService; + let payoutOrderRepo: PayoutOrderRepository; + let strategy: EvmStrategy; + + beforeEach(() => { + service = createEvmServiceMock(); + assetService = mock(); + payoutOrderRepo = mock(); + strategy = new Strategy(service as any, assetService, payoutOrderRepo); + }); + + it('exposes the expected blockchain', () => { + expect(strategy.blockchain).toBe(blockchain); + }); + + it(`exposes assetType ${isToken ? 'TOKEN' : 'COIN'}`, () => { + expect(strategy.assetType).toBe(assetType); + }); + + it('dispatchPayout broadcasts via the correct service method with a fresh (undefined) nonce', async () => { + // No payoutTxId → getOrderNonce short-circuits to undefined (no nonce reuse), so the send is + // called with an undefined nonce. + const order = createCustomPayoutOrder({ + payoutTxId: null, + destinationAddress: 'DEST_ADDR', + amount: 1.23, + asset: createCustomAsset({ dexName: 'PAYOUT_TOKEN' }), + }); + const send = isToken ? service.sendToken : service.sendNativeCoin; + send.mockResolvedValue('TX_BROADCASTED'); + + const result = await (strategy as any).dispatchPayout(order); + + expect(result).toBe('TX_BROADCASTED'); + if (isToken) { + expect(service.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount, undefined); + expect(service.sendNativeCoin).not.toHaveBeenCalled(); + } else { + expect(service.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount, undefined); + expect(service.sendToken).not.toHaveBeenCalled(); + } + }); + + it('getCurrentGasForTransaction delegates to the correct gas estimation method', async () => { + const token: Asset = createCustomAsset({ dexName: 'GAS_TOKEN' }); + const gas = isToken ? service.getCurrentGasForTokenTransaction : service.getCurrentGasForCoinTransaction; + gas.mockResolvedValue(0.00042); + + const result = await (strategy as any).getCurrentGasForTransaction(token); + + expect(result).toBe(0.00042); + if (isToken) { + expect(service.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(token); + expect(service.getCurrentGasForCoinTransaction).not.toHaveBeenCalled(); + } else { + expect(service.getCurrentGasForCoinTransaction).toHaveBeenCalledWith(); + expect(service.getCurrentGasForTokenTransaction).not.toHaveBeenCalled(); + } + }); + + it(`getFeeAsset resolves the fee asset via assetService.${feeAssetGetter}()`, async () => { + const feeAsset: Asset = createCustomAsset({ dexName: 'FEE_ASSET' }); + const getter = assetService[feeAssetGetter] as jest.Mock; + getter.mockResolvedValue(feeAsset); + + const result = await (strategy as any).getFeeAsset(); + + expect(result).toBe(feeAsset); + expect(getter).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-nonevm-strategies.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-nonevm-strategies.spec.ts new file mode 100644 index 0000000000..fc32ca84b3 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-nonevm-strategies.spec.ts @@ -0,0 +1,967 @@ +import { mock } from 'jest-mock-extended'; +import { ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { FeeResult } from '../../../interfaces'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutGroup } from '../../../services/base/payout-bitcoin-based.service'; +import { PayoutBitcoinTestnet4Service } from '../../../services/payout-bitcoin-testnet4.service'; +import { PayoutBitcoinService } from '../../../services/payout-bitcoin.service'; +import { PayoutCardanoService } from '../../../services/payout-cardano.service'; +import { PayoutFiroService } from '../../../services/payout-firo.service'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { PayoutMoneroService } from '../../../services/payout-monero.service'; +import { PayoutSolanaService } from '../../../services/payout-solana.service'; +import { PayoutTronService } from '../../../services/payout-tron.service'; +import { PayoutZanoService } from '../../../services/payout-zano.service'; +import { BitcoinTestnet4Strategy } from '../impl/bitcoin-testnet4.strategy'; +import { BitcoinStrategy } from '../impl/bitcoin.strategy'; +import { PayoutStrategy } from '../impl/base/payout.strategy'; +import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; +import { CardanoTokenStrategy } from '../impl/cardano-token.strategy'; +import { FiroStrategy } from '../impl/firo.strategy'; +import { InternetComputerCoinStrategy } from '../impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy } from '../impl/icp-token.strategy'; +import { MoneroStrategy } from '../impl/monero.strategy'; +import { SolanaCoinStrategy } from '../impl/solana-coin.strategy'; +import { SolanaTokenStrategy } from '../impl/solana-token.strategy'; +import { TronCoinStrategy } from '../impl/tron-coin.strategy'; +import { TronTokenStrategy } from '../impl/tron-token.strategy'; +import { ZanoCoinStrategy } from '../impl/zano-coin.strategy'; +import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; + +// Leaf-strategy coverage for the concrete non-EVM payout strategies. The registry spec only +// constructs these classes (hard-coding blockchain/assetType in its wrapper), so their own +// getters plus the leaf overrides (getFeeAsset, getCurrentGasForTransaction, dispatchPayout, +// estimateFee, doPayoutForContext, hasEnoughUnlockedBalance) are otherwise never executed. +// The shared base classes in impl/base/ are covered separately. Arkade/Spark/Lightning are +// already fully covered by payout-strategy-fee-completion.leaf.spec.ts and +// payout-designate-before-broadcast.strategy.spec.ts, so they are intentionally not repeated here. + +const CONTEXT = PayoutOrderContext.BUY_CRYPTO; + +// --------------------------------------------------------------------------------------------- +// Cardano / Solana / Tron / ICP coin & token leaves. These only add getters, getFeeAsset, +// getCurrentGasForTransaction and dispatchPayout on top of a shared base; each is exercised via +// its public entry point (getters directly, getFeeAsset via feeAsset(), getCurrentGasForTransaction +// via estimateFee(), dispatchPayout via doPayout()). +// --------------------------------------------------------------------------------------------- + +interface LeafSetup { + strategy: PayoutStrategy; + feeAsset: Asset; + gasAsset: Asset; + mockFeeAsset: () => void; + expectFeeAssetResolved: () => void; + mockGas: (gas: number) => void; + expectGasResolved: () => void; + mockDispatch: (txId: string) => void; + expectDispatched: (order: PayoutOrder) => void; + estimate: (asset: Asset) => Promise; + doPayout: (orders: PayoutOrder[]) => Promise; + makeOrder: () => PayoutOrder; +} + +interface LeafConfig { + name: string; + blockchain: Blockchain; + assetType: AssetType; + // ICP's base estimateFee echoes the passed asset; the others resolve the chain fee asset. + estimateReturnsFeeAsset: boolean; + setup: () => LeafSetup; +} + +function repoSaveEcho(payoutOrderRepo: PayoutOrderRepository): void { + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); +} + +function makeConfirmedCoinOrder(): PayoutOrder { + return createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + destinationAddress: 'DEST_ADDR', + amount: 5, + }); +} + +function makeConfirmedTokenOrder(tokenAsset: Asset): PayoutOrder { + return createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + destinationAddress: 'DEST_ADDR', + amount: 5, + asset: tokenAsset, + }); +} + +function runNonEvmLeafSuite(config: LeafConfig): void { + describe(config.name, () => { + it(`exposes the ${config.blockchain} blockchain and the ${config.assetType} asset type`, () => { + const s = config.setup(); + + expect(s.strategy.blockchain).toBe(config.blockchain); + expect(s.strategy.assetType).toBe(config.assetType); + }); + + it('feeAsset() delegates to the AssetService fee-asset getter', async () => { + const s = config.setup(); + s.mockFeeAsset(); + + await expect(s.strategy.feeAsset()).resolves.toBe(s.feeAsset); + + s.expectFeeAssetResolved(); + }); + + it('estimateFee(...) delegates the gas lookup to the chain service and returns the fee', async () => { + const s = config.setup(); + s.mockFeeAsset(); + s.mockGas(0.123); + + const result = await s.estimate(s.gasAsset); + + s.expectGasResolved(); + expect(result.amount).toBe(0.123); + expect(result.asset).toBe(config.estimateReturnsFeeAsset ? s.feeAsset : s.gasAsset); + }); + + it('doPayout(...) broadcasts through the chain service and reaches PAYOUT_PENDING', async () => { + const s = config.setup(); + s.mockDispatch('TX_LEAF'); + const order = s.makeOrder(); + + await s.doPayout([order]); + + s.expectDispatched(order); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_LEAF'); + }); + }); +} + +describe('Payout non-EVM leaf strategies', () => { + beforeAll(() => { + new ConfigService(); // sets the module-level Config (Config.blockchain.firo used by FiroStrategy#estimateFee) + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ------------------------------- Cardano ------------------------------- + + runNonEvmLeafSuite({ + name: 'CardanoCoinStrategy', + blockchain: Blockchain.CARDANO, + assetType: AssetType.COIN, + estimateReturnsFeeAsset: true, + setup: () => { + const cardanoService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'ADA' }); + const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new CardanoCoinStrategy(cardanoService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getCardanoCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getCardanoCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(cardanoService, 'getCurrentGasForCoinTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(cardanoService.getCurrentGasForCoinTransaction).toHaveBeenCalledTimes(1), + mockDispatch: (tx) => jest.spyOn(cardanoService, 'sendNativeCoin').mockResolvedValue(tx), + expectDispatched: (order) => + expect(cardanoService.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: makeConfirmedCoinOrder, + }; + }, + }); + + runNonEvmLeafSuite({ + name: 'CardanoTokenStrategy', + blockchain: Blockchain.CARDANO, + assetType: AssetType.TOKEN, + estimateReturnsFeeAsset: true, + setup: () => { + const cardanoService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'ADA' }); + const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new CardanoTokenStrategy(cardanoService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getCardanoCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getCardanoCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(cardanoService, 'getCurrentGasForTokenTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(cardanoService.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(gasAsset), + mockDispatch: (tx) => jest.spyOn(cardanoService, 'sendToken').mockResolvedValue(tx), + expectDispatched: (order) => + expect(cardanoService.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: () => makeConfirmedTokenOrder(createCustomAsset({ id: 7, type: AssetType.TOKEN })), + }; + }, + }); + + // ------------------------------- Solana ------------------------------- + + runNonEvmLeafSuite({ + name: 'SolanaCoinStrategy', + blockchain: Blockchain.SOLANA, + assetType: AssetType.COIN, + estimateReturnsFeeAsset: true, + setup: () => { + const solanaService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'SOL' }); + const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new SolanaCoinStrategy(solanaService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getSolanaCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getSolanaCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(solanaService, 'getCurrentGasForCoinTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(solanaService.getCurrentGasForCoinTransaction).toHaveBeenCalledTimes(1), + mockDispatch: (tx) => jest.spyOn(solanaService, 'sendNativeCoin').mockResolvedValue(tx), + expectDispatched: (order) => + expect(solanaService.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: makeConfirmedCoinOrder, + }; + }, + }); + + runNonEvmLeafSuite({ + name: 'SolanaTokenStrategy', + blockchain: Blockchain.SOLANA, + assetType: AssetType.TOKEN, + estimateReturnsFeeAsset: true, + setup: () => { + const solanaService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'SOL' }); + const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new SolanaTokenStrategy(solanaService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getSolanaCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getSolanaCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(solanaService, 'getCurrentGasForTokenTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(solanaService.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(gasAsset), + mockDispatch: (tx) => jest.spyOn(solanaService, 'sendToken').mockResolvedValue(tx), + expectDispatched: (order) => + expect(solanaService.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: () => makeConfirmedTokenOrder(createCustomAsset({ id: 7, type: AssetType.TOKEN })), + }; + }, + }); + + // ------------------------------- Tron ------------------------------- + + runNonEvmLeafSuite({ + name: 'TronCoinStrategy', + blockchain: Blockchain.TRON, + assetType: AssetType.COIN, + estimateReturnsFeeAsset: true, + setup: () => { + const tronService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'TRX' }); + const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new TronCoinStrategy(tronService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getTronCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getTronCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(tronService, 'getCurrentGasForCoinTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(tronService.getCurrentGasForCoinTransaction).toHaveBeenCalledTimes(1), + mockDispatch: (tx) => jest.spyOn(tronService, 'sendNativeCoin').mockResolvedValue(tx), + expectDispatched: (order) => + expect(tronService.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: makeConfirmedCoinOrder, + }; + }, + }); + + runNonEvmLeafSuite({ + name: 'TronTokenStrategy', + blockchain: Blockchain.TRON, + assetType: AssetType.TOKEN, + estimateReturnsFeeAsset: true, + setup: () => { + const tronService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'TRX' }); + const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new TronTokenStrategy(tronService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getTronCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getTronCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(tronService, 'getCurrentGasForTokenTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(tronService.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(gasAsset), + mockDispatch: (tx) => jest.spyOn(tronService, 'sendToken').mockResolvedValue(tx), + expectDispatched: (order) => + expect(tronService.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: () => makeConfirmedTokenOrder(createCustomAsset({ id: 7, type: AssetType.TOKEN })), + }; + }, + }); + + // ------------------------------- ICP ------------------------------- + + runNonEvmLeafSuite({ + name: 'InternetComputerCoinStrategy', + blockchain: Blockchain.INTERNET_COMPUTER, + assetType: AssetType.COIN, + estimateReturnsFeeAsset: false, + setup: () => { + const icpService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'ICP' }); + const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new InternetComputerCoinStrategy(icpService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getInternetComputerCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getInternetComputerCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(icpService, 'getCurrentGasForCoinTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(icpService.getCurrentGasForCoinTransaction).toHaveBeenCalledTimes(1), + mockDispatch: (tx) => jest.spyOn(icpService, 'sendNativeCoin').mockResolvedValue(tx), + expectDispatched: (order) => + expect(icpService.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: makeConfirmedCoinOrder, + }; + }, + }); + + runNonEvmLeafSuite({ + name: 'InternetComputerTokenStrategy', + blockchain: Blockchain.INTERNET_COMPUTER, + assetType: AssetType.TOKEN, + estimateReturnsFeeAsset: false, + setup: () => { + const icpService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'ICP' }); + const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new InternetComputerTokenStrategy(icpService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getInternetComputerCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getInternetComputerCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(icpService, 'getCurrentGasForTokenTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(icpService.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(gasAsset), + mockDispatch: (tx) => jest.spyOn(icpService, 'sendToken').mockResolvedValue(tx), + expectDispatched: (order) => + expect(icpService.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: () => makeConfirmedTokenOrder(createCustomAsset({ id: 7, type: AssetType.TOKEN })), + }; + }, + }); + + // ------------------------------- Zano ------------------------------- + + describe('ZanoCoinStrategy', () => { + let notificationService: NotificationService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutZanoService: PayoutZanoService; + let assetService: AssetService; + let strategy: ZanoCoinStrategy; + + beforeEach(() => { + notificationService = mock(); + payoutOrderRepo = mock(); + payoutZanoService = mock(); + assetService = mock(); + strategy = new ZanoCoinStrategy(notificationService, payoutOrderRepo, payoutZanoService, assetService); + }); + + it('exposes the Zano blockchain and a COIN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.ZANO); + expect(strategy.assetType).toBe(AssetType.COIN); + }); + + it('hasEnoughUnlockedBalance(...) returns true when the unlocked coin balance covers the total', async () => { + jest.spyOn(payoutZanoService, 'getUnlockedCoinBalance').mockResolvedValue(10); + const orders = [createCustomPayoutOrder({ amount: 1 }), createCustomPayoutOrder({ amount: 2 })]; + + await expect(strategy.hasEnoughUnlockedBalance(orders)).resolves.toBe(true); + + expect(payoutZanoService.getUnlockedCoinBalance).toHaveBeenCalledTimes(1); + }); + + it('hasEnoughUnlockedBalance(...) returns false when the unlocked coin balance is short', async () => { + jest.spyOn(payoutZanoService, 'getUnlockedCoinBalance').mockResolvedValue(2); + const orders = [createCustomPayoutOrder({ amount: 1 }), createCustomPayoutOrder({ amount: 2 })]; + + await expect(strategy.hasEnoughUnlockedBalance(orders)).resolves.toBe(false); + }); + + it('dispatchPayout(...) delegates the group to PayoutZanoService#sendCoins(...)', async () => { + jest.spyOn(payoutZanoService, 'sendCoins').mockResolvedValue('ZANO_COIN_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayout(CONTEXT, payout)).resolves.toBe('ZANO_COIN_TX'); + + expect(payoutZanoService.sendCoins).toHaveBeenCalledWith(payout); + }); + }); + + describe('ZanoTokenStrategy', () => { + let notificationService: NotificationService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutZanoService: PayoutZanoService; + let assetService: AssetService; + let strategy: ZanoTokenStrategy; + + beforeEach(() => { + notificationService = mock(); + payoutOrderRepo = mock(); + payoutZanoService = mock(); + assetService = mock(); + strategy = new ZanoTokenStrategy(notificationService, payoutOrderRepo, payoutZanoService, assetService); + }); + + it('exposes the Zano blockchain and a TOKEN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.ZANO); + expect(strategy.assetType).toBe(AssetType.TOKEN); + }); + + it('hasEnoughUnlockedBalance(...) checks the unlocked token balance for the order asset', async () => { + const tokenAsset = createCustomAsset({ id: 7, type: AssetType.TOKEN }); + jest.spyOn(payoutZanoService, 'getUnlockedTokenBalance').mockResolvedValue(10); + const orders = [ + createCustomPayoutOrder({ amount: 1, asset: tokenAsset }), + createCustomPayoutOrder({ amount: 2, asset: tokenAsset }), + ]; + + await expect(strategy.hasEnoughUnlockedBalance(orders)).resolves.toBe(true); + + expect(payoutZanoService.getUnlockedTokenBalance).toHaveBeenCalledWith(tokenAsset); + }); + + it('hasEnoughUnlockedBalance(...) returns false when the unlocked token balance is short', async () => { + const tokenAsset = createCustomAsset({ id: 7, type: AssetType.TOKEN }); + jest.spyOn(payoutZanoService, 'getUnlockedTokenBalance').mockResolvedValue(2); + const orders = [ + createCustomPayoutOrder({ amount: 1, asset: tokenAsset }), + createCustomPayoutOrder({ amount: 2, asset: tokenAsset }), + ]; + + await expect(strategy.hasEnoughUnlockedBalance(orders)).resolves.toBe(false); + }); + + it('dispatchPayout(...) delegates the group and token to PayoutZanoService#sendTokens(...)', async () => { + const tokenAsset = createCustomAsset({ id: 7, type: AssetType.TOKEN }); + jest.spyOn(payoutZanoService, 'sendTokens').mockResolvedValue('ZANO_TOKEN_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayout(CONTEXT, payout, tokenAsset)).resolves.toBe('ZANO_TOKEN_TX'); + + expect(payoutZanoService.sendTokens).toHaveBeenCalledWith(payout, tokenAsset); + }); + }); + + // ------------------------------- Bitcoin ------------------------------- + + describe('BitcoinStrategy', () => { + let notificationService: NotificationService; + let bitcoinService: PayoutBitcoinService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let strategy: BitcoinStrategyWrapper; + + beforeEach(() => { + notificationService = mock(); + bitcoinService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + strategy = new BitcoinStrategyWrapper(notificationService, bitcoinService, payoutOrderRepo, assetService); + }); + + it('exposes the Bitcoin blockchain and an undefined asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.BITCOIN); + expect(strategy.assetType).toBeUndefined(); + }); + + it('estimateFee() derives the BTC fee from the current fee rate and the average tx size', async () => { + const feeAsset = createCustomAsset({ name: 'BTC' }); + jest.spyOn(bitcoinService, 'getCurrentFeeRate').mockResolvedValue(100); + jest.spyOn(assetService, 'getBtcCoin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(); + + expect(bitcoinService.getCurrentFeeRate).toHaveBeenCalledTimes(1); + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.00014, 8); // 140 vBytes * 100 / 1e8 + }); + + it('feeAsset() delegates to AssetService#getBtcCoin()', async () => { + const feeAsset = createCustomAsset({ name: 'BTC' }); + jest.spyOn(assetService, 'getBtcCoin').mockResolvedValue(feeAsset); + + await expect(strategy.feeAsset()).resolves.toBe(feeAsset); + + expect(assetService.getBtcCoin).toHaveBeenCalledTimes(1); + }); + + it('dispatchPayout(...) delegates to PayoutBitcoinService#sendUtxoToMany(...)', async () => { + jest.spyOn(bitcoinService, 'sendUtxoToMany').mockResolvedValue('BTC_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('BTC_TX'); + + expect(bitcoinService.sendUtxoToMany).toHaveBeenCalledWith(CONTEXT, payout); + }); + + it('doPayoutForContext(...) skips empty groups and sends the non-empty ones', async () => { + const order = createCustomPayoutOrder({}); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[], [order]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [order]); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith(CONTEXT, [order]); + }); + + it('doPayoutForContext(...) swallows a failing group without propagating the error', async () => { + const order = createCustomPayoutOrder({}); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[order]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockRejectedValue(new Error('btc send failed')); + + await expect(strategy.doPayoutForContextWrapper(CONTEXT, [order])).resolves.toBeUndefined(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------- Bitcoin Testnet4 ------------------------------- + + describe('BitcoinTestnet4Strategy', () => { + let notificationService: NotificationService; + let bitcoinTestnet4Service: PayoutBitcoinTestnet4Service; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let strategy: BitcoinTestnet4StrategyWrapper; + + beforeEach(() => { + notificationService = mock(); + bitcoinTestnet4Service = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + strategy = new BitcoinTestnet4StrategyWrapper( + notificationService, + bitcoinTestnet4Service, + payoutOrderRepo, + assetService, + ); + }); + + it('exposes the Bitcoin Testnet4 blockchain and an undefined asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.BITCOIN_TESTNET4); + expect(strategy.assetType).toBeUndefined(); + }); + + it('estimateFee() derives the fee from the current fee rate and the average tx size', async () => { + const feeAsset = createCustomAsset({ name: 'tBTC' }); + jest.spyOn(bitcoinTestnet4Service, 'getCurrentFeeRate').mockResolvedValue(100); + jest.spyOn(assetService, 'getBitcoinTestnet4Coin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(); + + expect(bitcoinTestnet4Service.getCurrentFeeRate).toHaveBeenCalledTimes(1); + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.00014, 8); // 140 vBytes * 100 / 1e8 + }); + + it('feeAsset() delegates to AssetService#getBitcoinTestnet4Coin()', async () => { + const feeAsset = createCustomAsset({ name: 'tBTC' }); + jest.spyOn(assetService, 'getBitcoinTestnet4Coin').mockResolvedValue(feeAsset); + + await expect(strategy.feeAsset()).resolves.toBe(feeAsset); + + expect(assetService.getBitcoinTestnet4Coin).toHaveBeenCalledTimes(1); + }); + + it('dispatchPayout(...) delegates to PayoutBitcoinTestnet4Service#sendUtxoToMany(...)', async () => { + jest.spyOn(bitcoinTestnet4Service, 'sendUtxoToMany').mockResolvedValue('tBTC_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('tBTC_TX'); + + expect(bitcoinTestnet4Service.sendUtxoToMany).toHaveBeenCalledWith(CONTEXT, payout); + }); + + it('doPayoutForContext(...) skips empty groups and sends the non-empty ones', async () => { + const order = createCustomPayoutOrder({}); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[], [order]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [order]); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith(CONTEXT, [order]); + }); + + it('doPayoutForContext(...) swallows a failing group without propagating the error', async () => { + const order = createCustomPayoutOrder({}); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[order]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockRejectedValue(new Error('tbtc send failed')); + + await expect(strategy.doPayoutForContextWrapper(CONTEXT, [order])).resolves.toBeUndefined(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------- Monero ------------------------------- + + describe('MoneroStrategy', () => { + let notificationService: NotificationService; + let payoutMoneroService: PayoutMoneroService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let strategy: MoneroStrategyWrapper; + + beforeEach(() => { + notificationService = mock(); + payoutMoneroService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + strategy = new MoneroStrategyWrapper(notificationService, payoutMoneroService, payoutOrderRepo, assetService); + }); + + it('exposes the Monero blockchain and an undefined asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.MONERO); + expect(strategy.assetType).toBeUndefined(); + }); + + it('estimateFee() derives the XMR fee from the estimated fee rate and the average tx size', async () => { + const feeAsset = createCustomAsset({ name: 'XMR' }); + jest.spyOn(payoutMoneroService, 'getEstimatedFee').mockResolvedValue(2); + jest.spyOn(assetService, 'getMoneroCoin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(); + + expect(payoutMoneroService.getEstimatedFee).toHaveBeenCalledTimes(1); + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBe(3200); // 1600 Bytes * 2 + }); + + it('getFeeAsset() delegates to AssetService#getMoneroCoin()', async () => { + const feeAsset = createCustomAsset({ name: 'XMR' }); + jest.spyOn(assetService, 'getMoneroCoin').mockResolvedValue(feeAsset); + + await expect(strategy.feeAsset()).resolves.toBe(feeAsset); + + expect(assetService.getMoneroCoin).toHaveBeenCalledTimes(1); + }); + + it('dispatchPayout(...) delegates to PayoutMoneroService#sendToMany(...)', async () => { + jest.spyOn(payoutMoneroService, 'sendToMany').mockResolvedValue('XMR_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('XMR_TX'); + + expect(payoutMoneroService.sendToMany).toHaveBeenCalledWith(CONTEXT, payout); + }); + + it('doPayoutForContext(...) splices groups up to the size cap and pays out until orders are drained', async () => { + const orders = Array.from({ length: 16 }, (_, i) => createCustomPayoutOrder({ id: i + 1, amount: 1 })); + jest.spyOn(payoutMoneroService, 'getUnlockedBalance').mockResolvedValue(100); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, orders); + + expect(sendSpy).toHaveBeenCalledTimes(2); + expect((sendSpy.mock.calls[0][1] as PayoutOrder[]).length).toBe(15); // maxSize cap + expect((sendSpy.mock.calls[1][1] as PayoutOrder[]).length).toBe(1); // remaining order + }); + + it('doPayoutForContext(...) stops when the next order exceeds the unlocked balance', async () => { + const order = createCustomPayoutOrder({ amount: 5 }); + jest.spyOn(payoutMoneroService, 'getUnlockedBalance').mockResolvedValue(3); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [order]); + + expect(sendSpy).not.toHaveBeenCalled(); + expect(payoutMoneroService.getUnlockedBalance).toHaveBeenCalledTimes(1); + }); + + it('doPayoutForContext(...) stops immediately when there is no unlocked balance', async () => { + const order = createCustomPayoutOrder({ amount: 1 }); + jest.spyOn(payoutMoneroService, 'getUnlockedBalance').mockResolvedValue(0); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [order]); + + expect(sendSpy).not.toHaveBeenCalled(); + expect(payoutMoneroService.getUnlockedBalance).toHaveBeenCalledTimes(1); + }); + + it('doPayoutForContext(...) breaks out of the loop when a send throws', async () => { + const order = createCustomPayoutOrder({ amount: 1 }); + jest.spyOn(payoutMoneroService, 'getUnlockedBalance').mockResolvedValue(100); + const sendSpy = jest.spyOn(strategy as any, 'send').mockRejectedValue(new Error('xmr send failed')); + + await expect(strategy.doPayoutForContextWrapper(CONTEXT, [order])).resolves.toBeUndefined(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------- Firo ------------------------------- + + describe('FiroStrategy', () => { + let notificationService: NotificationService; + let firoService: PayoutFiroService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let strategy: FiroStrategyWrapper; + + beforeEach(() => { + notificationService = mock(); + firoService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + strategy = new FiroStrategyWrapper(notificationService, firoService, payoutOrderRepo, assetService); + }); + + it('exposes the Firo blockchain and an undefined asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.FIRO); + expect(strategy.assetType).toBeUndefined(); + }); + + it('estimateFee(...) uses the transparent tx size when no Spark address is given', async () => { + const feeAsset = createCustomAsset({ name: 'FIRO' }); + const isSparkSpy = jest.spyOn(CryptoService, 'isFiroSparkAddress'); + jest.spyOn(firoService, 'getCurrentFeeRate').mockResolvedValue(100); + jest.spyOn(assetService, 'getFiroCoin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(feeAsset); + + expect(isSparkSpy).not.toHaveBeenCalled(); // short-circuited by the missing address + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.000225, 8); // 225 bytes * 100 / 1e8 + }); + + it('estimateFee(...) uses the Spark mint tx size for a Spark address', async () => { + const feeAsset = createCustomAsset({ name: 'FIRO' }); + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(true); + jest.spyOn(firoService, 'getCurrentFeeRate').mockResolvedValue(100); + jest.spyOn(assetService, 'getFiroCoin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(feeAsset, 'SPARK_ADDR'); + + expect(CryptoService.isFiroSparkAddress).toHaveBeenCalledWith('SPARK_ADDR'); + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.00048, 8); // 480 bytes * 100 / 1e8 + }); + + it('getFeeAsset() delegates to AssetService#getFiroCoin()', async () => { + const feeAsset = createCustomAsset({ name: 'FIRO' }); + jest.spyOn(assetService, 'getFiroCoin').mockResolvedValue(feeAsset); + + await expect(strategy.feeAsset()).resolves.toBe(feeAsset); + + expect(assetService.getFiroCoin).toHaveBeenCalledTimes(1); + }); + + it('dispatchPayout(...) sends a transparent group via sendUtxoToMany(...)', async () => { + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(false); + jest.spyOn(firoService, 'sendUtxoToMany').mockResolvedValue('FIRO_TX'); + const payout: PayoutGroup = [{ addressTo: 'T1', amount: 1 }]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('FIRO_TX'); + + expect(firoService.sendUtxoToMany).toHaveBeenCalledWith(CONTEXT, payout); + expect(firoService.mintSpark).not.toHaveBeenCalled(); + }); + + it('dispatchPayout(...) mints an all-Spark group via mintSpark(...)', async () => { + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(true); + jest.spyOn(firoService, 'mintSpark').mockResolvedValue('FIRO_MINT_TX'); + const payout: PayoutGroup = [ + { addressTo: 'S1', amount: 1 }, + { addressTo: 'S2', amount: 2 }, + ]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('FIRO_MINT_TX'); + + expect(firoService.mintSpark).toHaveBeenCalledWith(payout); + expect(firoService.sendUtxoToMany).not.toHaveBeenCalled(); + }); + + it('dispatchPayout(...) rejects a mixed Spark/transparent group', () => { + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockImplementation((addr) => addr === 'S1'); + const payout: PayoutGroup = [ + { addressTo: 'S1', amount: 1 }, + { addressTo: 'T1', amount: 2 }, + ]; + + // dispatchPayout is a non-async method that throws synchronously on a mixed group (firo.strategy.ts:92), + // so it must be asserted as a synchronous throw rather than a rejected promise. + expect(() => strategy.dispatchPayoutWrapper(CONTEXT, payout)).toThrow( + 'Mixed Spark/transparent payout group detected', + ); + + expect(firoService.mintSpark).not.toHaveBeenCalled(); + expect(firoService.sendUtxoToMany).not.toHaveBeenCalled(); + }); + + it('dispatchPayout(...) treats an empty group as transparent', async () => { + const isSparkSpy = jest.spyOn(CryptoService, 'isFiroSparkAddress'); + jest.spyOn(firoService, 'sendUtxoToMany').mockResolvedValue('FIRO_EMPTY_TX'); + const payout: PayoutGroup = []; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('FIRO_EMPTY_TX'); + + expect(isSparkSpy).not.toHaveBeenCalled(); // short-circuited by the empty group + expect(firoService.sendUtxoToMany).toHaveBeenCalledWith(CONTEXT, payout); + }); + + it('doPayoutForContext(...) splits orders into Spark and transparent groups and sends both', async () => { + const sparkOrder = createCustomPayoutOrder({ id: 1, destinationAddress: 'SPARK_ADDR' }); + const transparentOrder = createCustomPayoutOrder({ id: 2, destinationAddress: 'TRANS_ADDR' }); + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockImplementation((addr) => addr === 'SPARK_ADDR'); + jest + .spyOn(strategy as any, 'createPayoutGroups') + .mockImplementation((...args: unknown[]) => [[], args[0] as PayoutOrder[]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [sparkOrder, transparentOrder]); + + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy).toHaveBeenNthCalledWith(1, CONTEXT, [sparkOrder]); + expect(sendSpy).toHaveBeenNthCalledWith(2, CONTEXT, [transparentOrder]); + }); + + it('doPayoutForContext(...) skips the transparent branch when every order is Spark', async () => { + const sparkOrder = createCustomPayoutOrder({ id: 1, destinationAddress: 'SPARK_ADDR' }); + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(true); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[sparkOrder]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [sparkOrder]); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith(CONTEXT, [sparkOrder]); + }); + + it('doPayoutForContext(...) swallows a failing transparent group without propagating the error', async () => { + const transparentOrder = createCustomPayoutOrder({ id: 2, destinationAddress: 'TRANS_ADDR' }); + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(false); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[transparentOrder]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockRejectedValue(new Error('firo send failed')); + + await expect(strategy.doPayoutForContextWrapper(CONTEXT, [transparentOrder])).resolves.toBeUndefined(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + }); +}); + +// --------------------------------------------------------------------------------------------- +// Wrappers exposing the protected doPayoutForContext / dispatchPayout of the Bitcoin-based leaves +// so they can be driven directly without the full base doPayout() plumbing. +// --------------------------------------------------------------------------------------------- + +class BitcoinStrategyWrapper extends BitcoinStrategy { + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } + + dispatchPayoutWrapper(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.dispatchPayout(context, payout); + } +} + +class BitcoinTestnet4StrategyWrapper extends BitcoinTestnet4Strategy { + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } + + dispatchPayoutWrapper(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.dispatchPayout(context, payout); + } +} + +class MoneroStrategyWrapper extends MoneroStrategy { + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } + + dispatchPayoutWrapper(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.dispatchPayout(context, payout); + } +} + +class FiroStrategyWrapper extends FiroStrategy { + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } + + dispatchPayoutWrapper(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.dispatchPayout(context, payout); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.base.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.base.spec.ts new file mode 100644 index 0000000000..7bfb5a45b9 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.base.spec.ts @@ -0,0 +1,693 @@ +import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutCardanoService } from '../../../services/payout-cardano.service'; +import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { PayoutSolanaService } from '../../../services/payout-solana.service'; +import { PayoutTronService } from '../../../services/payout-tron.service'; +import { CardanoStrategy } from '../impl/base/cardano.strategy'; +import { EvmStrategy } from '../impl/base/evm.strategy'; +import { InternetComputerStrategy } from '../impl/base/icp.strategy'; +import { SolanaStrategy } from '../impl/base/solana.strategy'; +import { TronStrategy } from '../impl/base/tron.strategy'; + +function withPricingService(strategy: any, pricingService: PricingService): void { + // pricingService is an @Inject() readonly property on PayoutStrategy; there is no DI in the + // unit test, so define it directly on the instance. + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); +} + +describe('Payout strategy base classes - estimateFee / estimateBlockchainFee / checkPayoutCompletionData', () => { + beforeEach(() => { + new ConfigService(); // sets the module-level Config (Config.defaultVolumeDecimal used by recordPayoutFee) + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------------------------- + // EvmStrategy: estimateFee / estimateBlockchainFee only (checkPayoutCompletionData is fully + // covered by payout-evm-retry.spec.ts and must not be duplicated here). + // --------------------------------------------------------------------------------------------- + describe('EvmStrategy', () => { + let strategy: EvmStrategyWrapper; + let payoutEvmService: PayoutEvmService; + let payoutOrderRepo: PayoutOrderRepository; + let feeAsset: Asset; + + beforeEach(() => { + payoutEvmService = mock(); + payoutOrderRepo = mock(); + feeAsset = createDefaultAsset(); + strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, feeAsset); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and caches it per asset id', async () => { + const asset = createCustomAsset({ id: 5 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.002); + + const first: FeeResult = await strategy.estimateFee(asset); + const second: FeeResult = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset: feeAsset, amount: 0.002 }); + expect(second).toEqual({ asset: feeAsset, amount: 0.002 }); + expect(gasSpy).toHaveBeenCalledTimes(1); // AsyncCache: second call served from cache + expect(gasSpy).toHaveBeenCalledWith(asset); + + const otherAsset = createCustomAsset({ id: 55 }); + const third: FeeResult = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: feeAsset, amount: 0.002 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 6 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.003); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset: feeAsset, amount: 0.003 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) catches and logs errors from getPayoutCompletionData(...) without throwing', async () => { + const pricingService = mock(); + withPricingService(strategy, pricingService); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_ERR' }); + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockRejectedValue(new Error('rpc unavailable')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(`Error in checking completion of EVM payout order ${order.id}`), + expect.any(Error), + ); + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------------------- + // SolanaStrategy + // --------------------------------------------------------------------------------------------- + describe('SolanaStrategy', () => { + let strategy: SolanaStrategyWrapper; + let solanaService: PayoutSolanaService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let feeAsset: Asset; + let convertFn: jest.Mock; + + beforeEach(() => { + solanaService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + feeAsset = createDefaultAsset(); + strategy = new SolanaStrategyWrapper(solanaService, payoutOrderRepo, feeAsset); + withPricingService(strategy, pricingService); + convertFn = jest.fn().mockReturnValue(42); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and caches it per asset id', async () => { + const asset = createCustomAsset({ id: 11 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.0001); + + const first = await strategy.estimateFee(asset); + const second = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset: feeAsset, amount: 0.0001 }); + expect(second).toEqual({ asset: feeAsset, amount: 0.0001 }); + expect(gasSpy).toHaveBeenCalledTimes(1); + + const otherAsset = createCustomAsset({ id: 111 }); + const third = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: feeAsset, amount: 0.0001 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 12 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.0002); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset: feeAsset, amount: 0.0002 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) completes the order, records the fee and persists once when complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'SOL_TX' }); + jest.spyOn(solanaService, 'getPayoutCompletionData').mockResolvedValue([true, 0.00005]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(solanaService.getPayoutCompletionData).toHaveBeenCalledWith('SOL_TX'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.00005, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.00005, 42); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + }); + + it('checkPayoutCompletionData(...) leaves the order untouched when not yet complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'SOL_TX_2' }); + jest.spyOn(solanaService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + + it('checkPayoutCompletionData(...) catches and logs errors without throwing', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'SOL_TX_ERR' }); + jest.spyOn(solanaService, 'getPayoutCompletionData').mockRejectedValue(new Error('rpc down')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------------------- + // TronStrategy + // --------------------------------------------------------------------------------------------- + describe('TronStrategy', () => { + let strategy: TronStrategyWrapper; + let tronService: PayoutTronService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let feeAsset: Asset; + let convertFn: jest.Mock; + + beforeEach(() => { + tronService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + feeAsset = createDefaultAsset(); + strategy = new TronStrategyWrapper(tronService, payoutOrderRepo, feeAsset); + withPricingService(strategy, pricingService); + convertFn = jest.fn().mockReturnValue(7); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and caches it per asset id', async () => { + const asset = createCustomAsset({ id: 21 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(1.5); + + const first = await strategy.estimateFee(asset); + const second = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset: feeAsset, amount: 1.5 }); + expect(second).toEqual({ asset: feeAsset, amount: 1.5 }); + expect(gasSpy).toHaveBeenCalledTimes(1); + + const otherAsset = createCustomAsset({ id: 121 }); + const third = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: feeAsset, amount: 1.5 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 22 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(2.5); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset: feeAsset, amount: 2.5 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) completes the order, records the fee and persists once when complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TRX_TX' }); + jest.spyOn(tronService, 'getPayoutCompletionData').mockResolvedValue([true, 3.2]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(tronService.getPayoutCompletionData).toHaveBeenCalledWith('TRX_TX'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(3.2, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 3.2, 7); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + }); + + it('checkPayoutCompletionData(...) leaves the order untouched when not yet complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TRX_TX_2' }); + jest.spyOn(tronService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + + it('checkPayoutCompletionData(...) catches and logs errors without throwing', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TRX_TX_ERR' }); + jest.spyOn(tronService, 'getPayoutCompletionData').mockRejectedValue(new Error('node down')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------------------- + // CardanoStrategy + // --------------------------------------------------------------------------------------------- + describe('CardanoStrategy', () => { + let strategy: CardanoStrategyWrapper; + let cardanoService: PayoutCardanoService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let feeAsset: Asset; + let convertFn: jest.Mock; + + beforeEach(() => { + cardanoService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + feeAsset = createDefaultAsset(); + strategy = new CardanoStrategyWrapper(cardanoService, payoutOrderRepo, feeAsset); + withPricingService(strategy, pricingService); + convertFn = jest.fn().mockReturnValue(3); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and caches it per asset id', async () => { + const asset = createCustomAsset({ id: 31 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.17); + + const first = await strategy.estimateFee(asset); + const second = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset: feeAsset, amount: 0.17 }); + expect(second).toEqual({ asset: feeAsset, amount: 0.17 }); + expect(gasSpy).toHaveBeenCalledTimes(1); + + const otherAsset = createCustomAsset({ id: 131 }); + const third = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: feeAsset, amount: 0.17 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 32 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.19); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset: feeAsset, amount: 0.19 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) completes the order, records the fee and persists once when complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'ADA_TX' }); + jest.spyOn(cardanoService, 'getPayoutCompletionData').mockResolvedValue([true, 0.18]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(cardanoService.getPayoutCompletionData).toHaveBeenCalledWith('ADA_TX'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.18, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.18, 3); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + }); + + it('checkPayoutCompletionData(...) leaves the order untouched when not yet complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'ADA_TX_2' }); + jest.spyOn(cardanoService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + + it('checkPayoutCompletionData(...) catches and logs errors without throwing', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'ADA_TX_ERR' }); + jest.spyOn(cardanoService, 'getPayoutCompletionData').mockRejectedValue(new Error('node down')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------------------- + // InternetComputerStrategy (ICP): unlike the other chains, estimateFee(...) returns the *input* + // asset (not this.feeAsset()) and checkPayoutCompletionData(...) uses order.asset directly as the + // fee asset and forwards the order's asset to getPayoutCompletionData(...) only when it is a TOKEN. + // --------------------------------------------------------------------------------------------- + describe('InternetComputerStrategy', () => { + let strategy: IcpStrategyWrapper; + let internetComputerService: PayoutInternetComputerService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let convertFn: jest.Mock; + + beforeEach(() => { + internetComputerService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + strategy = new IcpStrategyWrapper(internetComputerService, payoutOrderRepo); + withPricingService(strategy, pricingService); + convertFn = jest.fn().mockReturnValue(9); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and returns the *input* asset (not feeAsset())', async () => { + const asset = createCustomAsset({ id: 41 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.0004); + + const first = await strategy.estimateFee(asset); + const second = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset, amount: 0.0004 }); + expect(second).toEqual({ asset, amount: 0.0004 }); + expect(gasSpy).toHaveBeenCalledTimes(1); // AsyncCache: second call served from cache + + const otherAsset = createCustomAsset({ id: 141 }); + const third = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: otherAsset, amount: 0.0004 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 42 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.0006); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset, amount: 0.0006 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) forwards the order asset to getPayoutCompletionData(...) for a TOKEN order and records the fee via order.asset when complete', async () => { + const tokenAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'ICP_TOKEN_TX', + asset: tokenAsset, + }); + jest.spyOn(internetComputerService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0009]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(internetComputerService.getPayoutCompletionData).toHaveBeenCalledWith('ICP_TOKEN_TX', tokenAsset); + expect(pricingService.getPrice).toHaveBeenCalledWith(tokenAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0009, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(tokenAsset, 0.0009, 9); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + }); + + it('checkPayoutCompletionData(...) passes undefined for a COIN order and still completes when complete', async () => { + const coinAsset = createCustomAsset({ id: 44, type: AssetType.COIN }); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'ICP_COIN_TX', + asset: coinAsset, + }); + jest.spyOn(internetComputerService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0001]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(internetComputerService.getPayoutCompletionData).toHaveBeenCalledWith('ICP_COIN_TX', undefined); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0001, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(coinAsset, 0.0001, 9); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + }); + + it('checkPayoutCompletionData(...) leaves the order untouched when not yet complete', async () => { + const coinAsset = createCustomAsset({ id: 45, type: AssetType.COIN }); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'ICP_COIN_TX_2', + asset: coinAsset, + }); + jest.spyOn(internetComputerService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + + it('checkPayoutCompletionData(...) catches and logs errors without throwing', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'ICP_TX_ERR' }); + jest.spyOn(internetComputerService, 'getPayoutCompletionData').mockRejectedValue(new Error('canister down')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); +}); + +class EvmStrategyWrapper extends EvmStrategy { + constructor( + payoutEvmService: PayoutEvmService, + payoutOrderRepo: PayoutOrderRepository, + private readonly feeAssetValue: Asset, + ) { + super(payoutEvmService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + // overridable spy target: `getCurrentGasForTransaction` is protected and cannot be spied on + // directly, so it delegates to this public method instead. + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(this.feeAssetValue); + } +} + +class SolanaStrategyWrapper extends SolanaStrategy { + constructor( + solanaService: PayoutSolanaService, + payoutOrderRepo: PayoutOrderRepository, + private readonly feeAssetValue: Asset, + ) { + super(solanaService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.SOLANA; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(this.feeAssetValue); + } +} + +class TronStrategyWrapper extends TronStrategy { + constructor( + tronService: PayoutTronService, + payoutOrderRepo: PayoutOrderRepository, + private readonly feeAssetValue: Asset, + ) { + super(tronService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.TRON; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(this.feeAssetValue); + } +} + +class CardanoStrategyWrapper extends CardanoStrategy { + constructor( + cardanoService: PayoutCardanoService, + payoutOrderRepo: PayoutOrderRepository, + private readonly feeAssetValue: Asset, + ) { + super(cardanoService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.CARDANO; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(this.feeAssetValue); + } +} + +class IcpStrategyWrapper extends InternetComputerStrategy { + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + throw new Error('Method not implemented.'); // never invoked: ICP uses order.asset/input asset directly + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.leaf.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.leaf.spec.ts new file mode 100644 index 0000000000..dbc91ba752 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.leaf.spec.ts @@ -0,0 +1,434 @@ +import { NotImplementedException } from '@nestjs/common'; +import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutArkadeService } from '../../../services/payout-arkade.service'; +import { PayoutLightningService } from '../../../services/payout-lightning.service'; +import { PayoutSparkService } from '../../../services/payout-spark.service'; +import { ArkadeStrategy } from '../impl/arkade.strategy'; +import { LightningStrategy } from '../impl/lightning.strategy'; +import { SparkStrategy } from '../impl/spark.strategy'; + +// Leaf-strategy coverage for the three non-EVM/non-Bitcoin-based strategies that implement +// FeeResult estimation and completion tracking directly (no shared base class): Arkade, Spark +// and Lightning. doPayout/designate-before-broadcast is already covered by +// payout-designate-before-broadcast.strategy.spec.ts - this file targets the remaining +// estimateFee/estimateBlockchainFee, checkPayoutCompletionData and the protected +// getFeeAsset/getCurrentGasForTransaction helpers. + +class ArkadeStrategyWrapper extends ArkadeStrategy { + getFeeAssetWrapper(): Promise { + return this.getFeeAsset(); + } + + getCurrentGasForTransactionWrapper(token: Asset): Promise { + return this.getCurrentGasForTransaction(token); + } +} + +class SparkStrategyWrapper extends SparkStrategy { + getFeeAssetWrapper(): Promise { + return this.getFeeAsset(); + } + + getCurrentGasForTransactionWrapper(token: Asset): Promise { + return this.getCurrentGasForTransaction(token); + } +} + +describe('Payout leaf strategies - estimateFee & completion', () => { + beforeEach(() => { + new ConfigService(); // sets the module-level Config (Config.defaultVolumeDecimal used by recordPayoutFee) + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('ArkadeStrategy', () => { + let arkadeService: PayoutArkadeService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let pricingService: PricingService; + let strategy: ArkadeStrategyWrapper; + let feeAsset: Asset; + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + arkadeService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + pricingService = mock(); + feeAsset = createCustomAsset({ name: 'ARKADE_BTC' }); + jest.spyOn(assetService, 'getArkadeCoin').mockResolvedValue(feeAsset); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as any); + + strategy = new ArkadeStrategyWrapper(arkadeService, payoutOrderRepo, assetService); + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + }); + + it('exposes the Arkade blockchain and a COIN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.ARKADE); + expect(strategy.assetType).toBe(AssetType.COIN); + }); + + it('estimateFee() resolves the fee asset via AssetService and returns a zero amount', async () => { + const result = await strategy.estimateFee(); + + expect(assetService.getArkadeCoin).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee() regardless of the passed asset', async () => { + const someAsset = createCustomAsset({ name: 'IRRELEVANT' }); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(someAsset); + + expect(estimateFeeSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + + it('getFeeAsset() delegates to AssetService#getArkadeCoin()', async () => { + const result = await strategy.getFeeAssetWrapper(); + + expect(assetService.getArkadeCoin).toHaveBeenCalledTimes(1); + expect(result).toBe(feeAsset); + }); + + it('getCurrentGasForTransaction(...) delegates to PayoutArkadeService#getCurrentFeeForTransaction(...)', async () => { + jest.spyOn(arkadeService, 'getCurrentFeeForTransaction').mockResolvedValue(123); + + const result = await strategy.getCurrentGasForTransactionWrapper(feeAsset); + + expect(arkadeService.getCurrentFeeForTransaction).toHaveBeenCalledWith(feeAsset); + expect(result).toBe(123); + }); + + it('getPayoutCompletionData(...) delegates to PayoutArkadeService#getPayoutCompletionData(...)', async () => { + jest.spyOn(arkadeService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0005]); + + const result = await strategy.getPayoutCompletionData('TX_ARKADE'); + + expect(arkadeService.getPayoutCompletionData).toHaveBeenCalledWith('TX_ARKADE'); + expect(result).toEqual([true, 0.0005]); + }); + + describe('#checkPayoutCompletionData(...)', () => { + it('complete: records the payout fee (via pricingService/Config), completes the order and persists once', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_ARK_OK' }); + jest.spyOn(arkadeService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0007]); + const convertFn = jest.fn().mockReturnValue(21); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(arkadeService.getPayoutCompletionData).toHaveBeenCalledWith('TX_ARK_OK'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0007, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.0007, 21); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('not complete: leaves the order untouched and does not persist', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'TX_ARK_PENDING', + }); + jest.spyOn(arkadeService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows errors from getPayoutCompletionData and never persists', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_ARK_ERR' }); + jest.spyOn(arkadeService, 'getPayoutCompletionData').mockRejectedValue(new Error('arkade node unreachable')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('SparkStrategy', () => { + let sparkService: PayoutSparkService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let pricingService: PricingService; + let strategy: SparkStrategyWrapper; + let feeAsset: Asset; + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + sparkService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + pricingService = mock(); + feeAsset = createCustomAsset({ name: 'SPARK_BTC' }); + jest.spyOn(assetService, 'getSparkCoin').mockResolvedValue(feeAsset); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as any); + + strategy = new SparkStrategyWrapper(sparkService, payoutOrderRepo, assetService); + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + }); + + it('exposes the Spark blockchain and a COIN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.SPARK); + expect(strategy.assetType).toBe(AssetType.COIN); + }); + + it('estimateFee() resolves the fee asset via AssetService and returns a zero amount', async () => { + const result = await strategy.estimateFee(); + + expect(assetService.getSparkCoin).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee() regardless of the passed asset', async () => { + const someAsset = createCustomAsset({ name: 'IRRELEVANT' }); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(someAsset); + + expect(estimateFeeSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + + it('getFeeAsset() delegates to AssetService#getSparkCoin()', async () => { + const result = await strategy.getFeeAssetWrapper(); + + expect(assetService.getSparkCoin).toHaveBeenCalledTimes(1); + expect(result).toBe(feeAsset); + }); + + it('getCurrentGasForTransaction(...) delegates to PayoutSparkService#getCurrentFeeForTransaction(...)', async () => { + jest.spyOn(sparkService, 'getCurrentFeeForTransaction').mockResolvedValue(456); + + const result = await strategy.getCurrentGasForTransactionWrapper(feeAsset); + + expect(sparkService.getCurrentFeeForTransaction).toHaveBeenCalledWith(feeAsset); + expect(result).toBe(456); + }); + + it('getPayoutCompletionData(...) delegates to PayoutSparkService#getPayoutCompletionData(...)', async () => { + jest.spyOn(sparkService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0003]); + + const result = await strategy.getPayoutCompletionData('TX_SPARK'); + + expect(sparkService.getPayoutCompletionData).toHaveBeenCalledWith('TX_SPARK'); + expect(result).toEqual([true, 0.0003]); + }); + + describe('#checkPayoutCompletionData(...)', () => { + it('complete: records the payout fee (via pricingService/Config), completes the order and persists once', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_SPK_OK' }); + jest.spyOn(sparkService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0002]); + const convertFn = jest.fn().mockReturnValue(13); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(sparkService.getPayoutCompletionData).toHaveBeenCalledWith('TX_SPK_OK'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0002, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.0002, 13); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('not complete: leaves the order untouched and does not persist', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'TX_SPK_PENDING', + }); + jest.spyOn(sparkService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows errors from getPayoutCompletionData and never persists', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_SPK_ERR' }); + jest.spyOn(sparkService, 'getPayoutCompletionData').mockRejectedValue(new Error('spark node unreachable')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('LightningStrategy', () => { + let assetService: AssetService; + let payoutLightningService: PayoutLightningService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let strategy: LightningStrategy; + let feeAsset: Asset; + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + assetService = mock(); + payoutLightningService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + feeAsset = createCustomAsset({ name: 'LN_BTC' }); + jest.spyOn(assetService, 'getLightningCoin').mockResolvedValue(feeAsset); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as any); + + strategy = new LightningStrategy(assetService, payoutLightningService, payoutOrderRepo); + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + }); + + it('exposes the Lightning blockchain (asset type is unset - LN has no on-chain asset distinction)', () => { + expect(strategy.blockchain).toBe(Blockchain.LIGHTNING); + expect(strategy.assetType).toBeUndefined(); + }); + + describe('#estimateBlockchainFee(...)', () => { + it('resolves the fee asset via getFeeAsset() and returns a zero amount', async () => { + const result = await strategy.estimateBlockchainFee(); + + expect(assetService.getLightningCoin).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + }); + + describe('#getFeeAsset(...)', () => { + it('delegates to AssetService#getLightningCoin()', async () => { + const result = await strategy.getFeeAsset(); + + expect(assetService.getLightningCoin).toHaveBeenCalledTimes(1); + expect(result).toBe(feeAsset); + }); + }); + + describe('#estimateFee(...)', () => { + it('throws NotImplementedException when the target asset differs from the reference asset', async () => { + const targetAsset = createCustomAsset({ id: 1 }); + const refAsset = createCustomAsset({ id: 2 }); + + await expect(strategy.estimateFee(targetAsset, 'lnbc_addr', 1, refAsset)).rejects.toThrow( + NotImplementedException, + ); + expect(payoutLightningService.getEstimatedFee).not.toHaveBeenCalled(); + }); + + it('estimates the fee via PayoutLightningService when target and reference asset match', async () => { + const sameAsset = createCustomAsset({ id: 5 }); + jest.spyOn(payoutLightningService, 'getEstimatedFee').mockResolvedValue(0.00001); + + const result = await strategy.estimateFee(sameAsset, 'lnbc_addr', 1000, sameAsset); + + expect(payoutLightningService.getEstimatedFee).toHaveBeenCalledWith('lnbc_addr', 1000); + expect(result).toEqual({ asset: feeAsset, amount: 0.00001 }); + }); + }); + + describe('#checkPayoutCompletionData(...)', () => { + it('health gate: does nothing when isHealthy() is false', async () => { + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(false); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_LN_SKIP' }); + const completeSpy = jest.spyOn(order, 'complete'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutLightningService.getPayoutCompletionData).not.toHaveBeenCalled(); + expect(completeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('complete: records the payout fee (via pricingService/Config), completes the order and persists once', async () => { + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(payoutLightningService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0000015]); + const convertFn = jest.fn().mockReturnValue(7); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_LN_OK' }); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutLightningService.getPayoutCompletionData).toHaveBeenCalledWith('TX_LN_OK'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0000015, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.0000015, 7); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('not complete: leaves the order untouched and does not persist', async () => { + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(payoutLightningService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'TX_LN_PENDING', + }); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows errors from getPayoutCompletionData and never persists', async () => { + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); + jest + .spyOn(payoutLightningService, 'getPayoutCompletionData') + .mockRejectedValue(new Error('lightning node unreachable')); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_LN_ERR' }); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-zano.strategy.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-zano.strategy.spec.ts new file mode 100644 index 0000000000..b1d113daf7 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-zano.strategy.spec.ts @@ -0,0 +1,163 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutGroup } from '../../../services/base/payout-bitcoin-based.service'; +import { PayoutZanoService } from '../../../services/payout-zano.service'; +import { ZanoStrategy } from '../impl/base/zano.strategy'; + +describe('ZanoStrategy', () => { + let strategy: ZanoStrategyWrapper; + + let notificationService: NotificationService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutZanoService: PayoutZanoService; + let assetService: AssetService; + + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + notificationService = mock(); + payoutOrderRepo = mock(); + payoutZanoService = mock(); + assetService = mock(); + + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + + strategy = new ZanoStrategyWrapper(notificationService, payoutOrderRepo, payoutZanoService, assetService); + }); + + it('exposes the Zano blockchain and a COIN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.ZANO); + expect(strategy.assetType).toBe(AssetType.COIN); + }); + + describe('#estimateFee(...)', () => { + it('returns the Zano fee asset and the service estimated fee', async () => { + const feeAsset = createCustomAsset({ name: 'ZANO' }); + jest.spyOn(assetService, 'getZanoCoin').mockResolvedValue(feeAsset); + jest.spyOn(payoutZanoService, 'getEstimatedFee').mockReturnValue(0.01); + + const result = await strategy.estimateFee(); + + expect(payoutZanoService.getEstimatedFee).toHaveBeenCalledTimes(1); + expect(assetService.getZanoCoin).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0.01 }); + }); + }); + + describe('#getFeeAsset(...)', () => { + it('delegates to AssetService#getZanoCoin()', async () => { + const feeAsset = createCustomAsset({ name: 'ZANO' }); + jest.spyOn(assetService, 'getZanoCoin').mockResolvedValue(feeAsset); + + const result = await strategy.getFeeAsset(); + + expect(assetService.getZanoCoin).toHaveBeenCalledTimes(1); + expect(result).toBe(feeAsset); + }); + }); + + describe('#doPayoutForContext(...)', () => { + it('pays out a group with sufficient unlocked balance via send(...)', async () => { + const orders = [ + createCustomPayoutOrder({ + id: 1, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + destinationAddress: 'ADDR_01', + amount: 1, + }), + ]; + jest.spyOn(strategy, 'hasEnoughUnlockedBalanceImpl').mockResolvedValue(true); + const dispatchSpy = jest.spyOn(strategy, 'dispatchPayoutImpl').mockResolvedValue('ZANO_TX'); + const loggerVerboseSpy = jest.spyOn((strategy as any).logger, 'verbose'); + + await strategy.doPayoutForContextWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(loggerVerboseSpy).toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledWith( + PayoutOrderContext.BUY_CRYPTO, + [{ addressTo: 'ADDR_01', amount: 1 }], + orders[0].asset, + ); + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(orders[0].payoutTxId).toBe('ZANO_TX'); + expect(repoSaveSpy).toHaveBeenCalled(); + }); + + it('logs and does not send when the unlocked balance is insufficient', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + jest.spyOn(strategy, 'hasEnoughUnlockedBalanceImpl').mockResolvedValue(false); + const dispatchSpy = jest.spyOn(strategy, 'dispatchPayoutImpl'); + const loggerInfoSpy = jest.spyOn((strategy as any).logger, 'info'); + + await strategy.doPayoutForContextWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(loggerInfoSpy).toHaveBeenCalledWith(expect.stringContaining('Insufficient unlocked balance')); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('skips empty payout groups (defensive guard) and only processes non-empty ones', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + // createPayoutGroups never emits an empty group for real input, so force one to reach the guard. + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[], [order]]); + const hasBalanceSpy = jest.spyOn(strategy, 'hasEnoughUnlockedBalanceImpl').mockResolvedValue(false); + + await strategy.doPayoutForContextWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(hasBalanceSpy).toHaveBeenCalledTimes(1); + expect(hasBalanceSpy).toHaveBeenCalledWith([order]); + }); + + it('catches an error from a group and continues without throwing', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + jest.spyOn(strategy, 'hasEnoughUnlockedBalanceImpl').mockRejectedValue(new Error('balance rpc down')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await expect(strategy.doPayoutForContextWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error in paying out a group'), + expect.any(Error), + ); + }); + }); +}); + +class ZanoStrategyWrapper extends ZanoStrategy { + protected readonly logger = new DfxLogger(ZanoStrategyWrapper); + + get blockchain(): Blockchain { + return Blockchain.ZANO; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + // Overridable per-test spy target for the abstract balance check. + hasEnoughUnlockedBalanceImpl: (orders: PayoutOrder[]) => Promise = () => Promise.resolve(true); + + async hasEnoughUnlockedBalance(orders: PayoutOrder[]): Promise { + return this.hasEnoughUnlockedBalanceImpl(orders); + } + + // Overridable per-test so #send(...) tests can simulate a chain dispatch without a real client. + dispatchPayoutImpl: (context: PayoutOrderContext, payout: PayoutGroup, token?: Asset) => Promise = () => + Promise.resolve('ZANO_TX'); + + async dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup, token?: Asset): Promise { + return this.dispatchPayoutImpl(context, payout, token); + } + + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.strategy.base.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.strategy.base.spec.ts new file mode 100644 index 0000000000..408356b8a4 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.strategy.base.spec.ts @@ -0,0 +1,271 @@ +import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../exceptions/payout-broadcast.exception'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { EvmStrategy } from '../impl/base/evm.strategy'; +import { PayoutStrategy } from '../impl/base/payout.strategy'; +import { PayoutStrategyRegistry } from '../impl/base/payout.strategy-registry'; + +describe('PayoutStrategy (base)', () => { + describe('#designateBeforeBroadcast(...)', () => { + let strategy: TestStrategyWrapper; + let payoutOrderRepo: PayoutOrderRepository; + + beforeEach(() => { + payoutOrderRepo = mock(); + + strategy = new TestStrategyWrapper(); + }); + + it('designates and persists the order when payoutTxId is not yet set', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + + await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + + expect(designateSpy).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + }); + + it('does not designate nor persist the order when payoutTxId is already set', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'OLD_TX' }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + + await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + + expect(designateSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + }); + }); + + describe('#handleBroadcastError(...)', () => { + let payoutOrderRepo: PayoutOrderRepository; + let repoSaveSpy: jest.SpyInstance; + + beforeAll(() => { + new ConfigService(); // sets module-level Config (Config.payout.maxPreBroadcastRetries) + }); + + beforeEach(() => { + payoutOrderRepo = mock(); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('rolls back to PREPARATION_CONFIRMED on a first-attempt plain error (provably pre-broadcast)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + + await strategy.callHandleBroadcastError(order, new Error('gas estimation failed'), payoutOrderRepo); + + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.retryCount).toBe(1); + expect(order.lastError).toBe('gas estimation failed'); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('does not roll back on a PayoutBroadcastException (send was reached, fail-closed)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + + await strategy.callHandleBroadcastError( + order, + new PayoutBroadcastException('tx may be in-flight'), + payoutOrderRepo, + ); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('does not roll back a re-entry with payoutTxId already set (would break speedup/expired-retry nonce reuse)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: 'OLD_TX' }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + + await strategy.callHandleBroadcastError(order, new Error('replacement transaction underpriced'), payoutOrderRepo); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBe('OLD_TX'); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('stops rolling back once retryCount reaches the cap (escalates to PAYOUT_UNCERTAIN downstream)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_DESIGNATED, + payoutTxId: null, + retryCount: Config.payout.maxPreBroadcastRetries, + }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + + await strategy.callHandleBroadcastError(order, new Error('gas estimation failed'), payoutOrderRepo); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.retryCount).toBe(Config.payout.maxPreBroadcastRetries); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('records String(e) as the failure message for a non-Error rejection', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: null }); + + await strategy.callHandleBroadcastError(order, 'rpc failure string', payoutOrderRepo); + + expect(order.lastError).toBe('rpc failure string'); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); + }); + + describe('#supportsSpeedup', () => { + it('defaults to false on the base strategy', () => { + const strategy = new TestStrategyWrapper(); + + expect(strategy.supportsSpeedup).toBe(false); + }); + + it('is overridden to true on EvmStrategy', () => { + const payoutEvmService = mock(); + const payoutOrderRepo = mock(); + const strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo); + + expect(strategy.supportsSpeedup).toBe(true); + }); + }); + + describe('#onModuleInit(...) / #onModuleDestroy(...)', () => { + let strategy: TestStrategyWrapper; + let registry: PayoutStrategyRegistry; + + beforeEach(() => { + strategy = new TestStrategyWrapper(); + registry = mock(); + + // registry is an @Inject() readonly property on PayoutStrategy; there is no DI in the unit + // test, so define it directly on the instance (direct assignment fails on a readonly field). + Object.defineProperty(strategy, 'registry', { value: registry, configurable: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers itself with the payout strategy registry on module init', () => { + strategy.onModuleInit(); + + expect(registry.add).toHaveBeenCalledWith( + { blockchain: Blockchain.BITCOIN, assetType: AssetType.COIN }, + strategy, + ); + }); + + it('deregisters itself from the payout strategy registry on module destroy', () => { + strategy.onModuleDestroy(); + + expect(registry.remove).toHaveBeenCalledWith({ blockchain: Blockchain.BITCOIN, assetType: AssetType.COIN }); + }); + }); + + describe('#canRetryFailedPayout(...)', () => { + it('defaults to false on the base strategy (whitelist approach: no retry unless overridden)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({}); + + await expect(strategy.canRetryFailedPayout(order)).resolves.toBe(false); + }); + }); + + describe('#feeAsset(...)', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('resolves the fee asset via getFeeAsset() once and caches it on subsequent calls', async () => { + const strategy = new TestStrategyWrapper(); + const asset = createDefaultAsset(); + const getFeeAssetSpy = jest.spyOn(strategy as any, 'getFeeAsset').mockResolvedValue(asset); + + const first = await strategy.feeAsset(); + const second = await strategy.feeAsset(); + + expect(first).toBe(asset); + expect(second).toBe(asset); + expect(getFeeAssetSpy).toHaveBeenCalledTimes(1); + }); + }); +}); + +class TestStrategyWrapper extends PayoutStrategy { + get blockchain(): Blockchain { + return Blockchain.BITCOIN; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + async doPayout(_orders: PayoutOrder[]): Promise { + throw new Error('Method not implemented.'); + } + + async checkPayoutCompletionData(_orders: PayoutOrder[]): Promise { + throw new Error('Method not implemented.'); + } + + async estimateFee(): Promise { + throw new Error('Method not implemented.'); + } + + async estimateBlockchainFee(): Promise { + throw new Error('Method not implemented.'); + } + + protected async getFeeAsset(): Promise { + throw new Error('Method not implemented.'); + } + + async callDesignateBeforeBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { + return this.designateBeforeBroadcast(order, repo); + } + + async callHandleBroadcastError(order: PayoutOrder, e: unknown, repo: PayoutOrderRepository): Promise { + return this.handleBroadcastError(order, e, repo); + } +} + +class EvmStrategyWrapper extends EvmStrategy { + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts index a37fa24ce5..061a63847f 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts @@ -45,12 +45,16 @@ export class ArkadeStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Arkade payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/bitcoin-based.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/bitcoin-based.strategy.ts index 396d78b6ac..6522db1756 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/bitcoin-based.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/bitcoin-based.strategy.ts @@ -5,6 +5,7 @@ import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { Util } from 'src/shared/utils/util'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { PayoutBroadcastException } from 'src/subdomains/supporting/payout/exceptions/payout-broadcast.exception'; import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; import { PayoutBitcoinBasedService, @@ -153,7 +154,12 @@ export abstract class BitcoinBasedStrategy extends PayoutStrategy { await this.trackPayoutFailure(orders, e); - if (e.message.includes('timeout')) throw e; + // A PayoutBroadcastException means the underlying client reached the actual on-chain + // broadcast call (tx may already be in-flight) - fail-closed, keep PAYOUT_DESIGNATED so + // the cron escalates to PAYOUT_UNCERTAIN instead of double-spending on rollback+retry. + // Any other error (including one that happens to mention "timeout") is provably + // pre-broadcast and safe to roll back for auto-retry. + if (e instanceof PayoutBroadcastException) throw e; await this.rollbackPayoutDesignation(orders); diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/cardano.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/cardano.strategy.ts index d615d154a6..36c1611ba2 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/cardano.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/cardano.strategy.ts @@ -37,12 +37,16 @@ export abstract class CardanoStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Cardano payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts index d9d60acc45..67057bd515 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts @@ -26,6 +26,10 @@ export abstract class EvmStrategy extends PayoutStrategy { protected abstract dispatchPayout(order: PayoutOrder): Promise; protected abstract getCurrentGasForTransaction(token?: Asset): Promise; + override get supportsSpeedup(): boolean { + return true; + } + async estimateFee(asset: Asset): Promise { const gasPerTransaction = await this.txFees.get(asset.id.toString(), () => this.getCurrentGasForTransaction(asset)); @@ -39,12 +43,17 @@ export abstract class EvmStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); + order.resetPayoutRetry(); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing EVM payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts index a6decb1a0b..11e4adf5d9 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts @@ -37,12 +37,16 @@ export abstract class InternetComputerStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing ICP payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts index a8b150ea5c..3a5646c719 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts @@ -1,9 +1,12 @@ import { Inject, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { PayoutOrder } from '../../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../../exceptions/payout-broadcast.exception'; +import { PayoutOrderRepository } from '../../../../repositories/payout-order.repository'; import { PayoutStrategyRegistry } from './payout.strategy-registry'; export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { @@ -39,5 +42,37 @@ export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { return false; } + // Speedup replaces a pending tx by reusing its nonce; only EVM implements this. + // Default false so speedupTransaction rejects chains without replacement semantics, + // which would otherwise broadcast a second, independent transaction (double payout). + get supportsSpeedup(): boolean { + return false; + } + + // Persist PAYOUT_DESIGNATED BEFORE broadcasting (fail-closed, mirrors the Bitcoin path): + // a reboot between broadcast and save must not leave the order re-selectable by the payout + // cron, which would double-pay. Only designates on the first attempt; a re-entry with + // payoutTxId already set (EVM speedup/expired-retry) keeps its status so nonce reuse stays intact. + protected async designateBeforeBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { + if (!order.payoutTxId) { + order.designatePayout(); + await repo.save(order); + } + } + + // Broadcast-boundary error handling shared by all non-Bitcoin strategies. A PayoutBroadcastException + // means the send was reached (tx may be in-flight) → fail-closed: leave PAYOUT_DESIGNATED for + // processFailedOrders → PAYOUT_UNCERTAIN. A plain error means the tx provably never left → roll back + // to PREPARATION_CONFIRMED for auto-retry, but only on the first attempt (payoutTxId unset — never + // break speedup/expired-retry nonce reuse) and capped by retryCount to escalate a permanent failure. + protected async handleBroadcastError(order: PayoutOrder, e: unknown, repo: PayoutOrderRepository): Promise { + const preBroadcast = !(e instanceof PayoutBroadcastException); + if (preBroadcast && !order.payoutTxId && order.retryCount < Config.payout.maxPreBroadcastRetries) { + order.recordPayoutFailure(e instanceof Error ? e.message : String(e)); + order.rollbackPayoutDesignation(); + await repo.save(order); + } + } + protected abstract getFeeAsset(): Promise; } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/solana.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/solana.strategy.ts index eeabe76922..29f760faaa 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/solana.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/solana.strategy.ts @@ -37,12 +37,16 @@ export abstract class SolanaStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Solana payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/tron.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/tron.strategy.ts index b7c5bb7151..729f7a3c89 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/tron.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/tron.strategy.ts @@ -37,12 +37,16 @@ export abstract class TronStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Tron payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts index 2e5b101df6..491812c5d8 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts @@ -39,6 +39,8 @@ export class LightningStrategy extends PayoutStrategy { if (await this.isHealthy()) { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const address = order.destinationAddress; const amount = order.amount; @@ -46,6 +48,8 @@ export class LightningStrategy extends PayoutStrategy { await this.finishDoPayout(order, txId); } catch (e) { this.logger.error(`Error while executing Lightning payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts index d9fc384e5c..08cec3d8f6 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts @@ -45,12 +45,16 @@ export class SparkStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Spark payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } From 52f987f229864bd2c818369754700cfafe16871f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:49:29 +0200 Subject: [PATCH 6/9] fix(realunit): require tax residence to cover address country (#4198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(realunit): require tax residence to cover address country Validate that addressCountry is among the declared tax residences (swissTaxResidence covers CH; countryAndTINs covers other countries). Always persist countryAndTINs on user_data.tin, including when personal data already exists. Multi-residence remains allowed. * fix(realunit): reject CH in countryAndTINs and validate multi-residence TINs Swiss tax residence is covered only by swissTaxResidence. countryAndTINs entries must be non-CH with a non-empty tin, including when multi-residence is declared alongside swissTaxResidence true. * fix(realunit): make user_data.tin updates audit-safe and non-destructive Never clear a non-null TIN by writing null (Swiss-only submissions must not erase prior foreign TINs). Write user_data.tin only after the registration row is durable, and always log previous→next before the column update so every mutation remains reconstructible. * docs(contributing): require auditable, non-destructive DB overwrites Document that mutating stored values is only allowed when the previous value remains recoverable, with before→after auditability and fail-closed behaviour when history would be lost. --- CONTRIBUTING.md | 24 ++ .../__tests__/realunit.service.spec.ts | 368 +++++++++++++++++- .../realunit/dto/realunit-registration.dto.ts | 12 +- .../supporting/realunit/realunit.service.ts | 123 +++++- 4 files changed, 501 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ebf787738..1aff32f14f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,7 @@ Missing any of these = changes requested. - **Clean API** — use DTOs, never expose entities, document with ApiProperty - **No over-engineering** — don't build it if the existing solution works - **Use existing packages** — use `@dfx.swiss/*` packages instead of duplicating logic +- **Auditable DB mutations** — no destructive overwrites; previous values must stay recoverable (see [Database / TypeORM → Auditable mutations](#auditable-mutations--no-destructive-overwrites-critical)) --- @@ -563,6 +564,29 @@ async process(data: OrderData): Promise { ... } ## Database / TypeORM +### Auditable mutations — no destructive overwrites (CRITICAL) + +Overwriting a stored value is only allowed when the **previous** value remains +**recoverable elsewhere in the database**. Every mutation must stay reconstructible: +it must always be clear **when** a field changed, **from which** previous value, and +**to which** new value. + +| Rule | Requirement | +|------|-------------| +| **No silent data loss** | If an overwrite can destroy information that is not still queryable on another durable row/event, it is a **bug**. | +| **Event before snapshot** | Write the immutable event first (e.g. registration `signedPayload`, superseded history rows, append-only log). Only then update a denormalised “current” column. | +| **Before → after audit** | For mutable snapshot columns, record `previous` and `next` (and identity/context) **before** the column update. If the audit write fails, do **not** change the column (fail closed). | +| **No destructive clear** | Do not set a non-null field to `null`/empty when the new business event does not itself retain that prior payload (e.g. clearing `user_data.tin` on a Swiss-only registration that omits `countryAndTINs`). | + +**Allowed pattern:** dual-store current snapshot + immutable history. + +- **History / event of record** — append-only or supersede-with-history rows (old row stays, `active = false`, payload intact). +- **Snapshot** — query-friendly “current” column, updated only after history is durable and only with an audit trail for the transition. + +**Not allowed:** in-place `UPDATE` that replaces or nulls a field when the old value exists nowhere else; “stale cleanup” that drops data without a recoverable prior record. + +When reviewing PRs that touch persistence, ask: *If this write runs, can an investigator still reconstruct the previous value and the change time from the DB alone?* If not, reject the change. + ### Entity Patterns ```typescript diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index aab04c664f..09ed4c2808 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -2254,6 +2254,10 @@ describe('RealUnitService', () => { expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); expect(forwardSpy).toHaveBeenCalled(); + // Personal KYC fields stay untouched when they already match. dto has no countryAndTINs and + // userData.tin is empty → tin persist is a no-op (no destructive null write). + expect(userDataService.updatePersonalData).not.toHaveBeenCalled(); + expect(userDataService.updateUserDataInternal).not.toHaveBeenCalled(); }); it('persists personal data for a first-time customer (no existing firstname) before forwarding', async () => { @@ -3055,6 +3059,152 @@ describe('RealUnitService', () => { await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); }); + // --- Tax residence must cover the residence (address) country --- + // countryAndTINs is NOT part of the EIP-712 envelope — attach it AFTER signing. + // + // Scenario matrix (service-level validateRegistrationDto): + // S1 CH + swissTaxResidence, countryAndTINs undefined/empty → PASS + // S2 DE + DE TIN → PASS + // S3 CH + swissTaxResidence + additional FR TIN → PASS + // S4 DE + swissTaxResidence + DE TIN → PASS + // S5 DE + multi (DE, FR, US) TINs → PASS + // N1 DE + swissTaxResidence, no countryAndTINs → reject (must include DE) + // N2 DE + only FR TIN → reject + // N3 CH + !swissTaxResidence + only FR → reject (CH not covered) + // N4 duplicate countries in countryAndTINs → reject + + const attachTins = (dto: any, countryAndTINs: { country: string; tin: string }[] | undefined) => { + dto.countryAndTINs = countryAndTINs; + return dto; + }; + + it('S1: passes when CH residence is covered by swissTaxResidence alone (countryAndTINs undefined)', async () => { + const dto = await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: true }), humanKyc()); + // no countryAndTINs attached + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S1: passes when CH residence is covered by swissTaxResidence alone (countryAndTINs empty)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: true }), humanKyc()), + [], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S2: passes when a DE residence is covered by a DE countryAndTINs entry', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [{ country: 'DE', tin: 'DE123456789' }], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S3: passes when a CH residence is covered and an additional FR tax country is declared', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: true }), humanKyc()), + [{ country: 'FR', tin: 'FR111111111' }], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S4: passes when a DE residence is covered by DE TIN even with swissTaxResidence true', async () => { + // Living in DE requires DE among tax residences; swissTaxResidence alone does NOT cover DE. + // With both flags set correctly (swiss + DE TIN) the registration is valid. + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: true }), humanKyc()), + [{ country: 'DE', tin: 'DE123456789' }], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S5: passes when a DE residence is covered and multiple additional tax countries are declared', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [ + { country: 'DE', tin: 'DE123456789' }, + { country: 'FR', tin: 'FR111111111' }, + { country: 'US', tin: 'US999999999' }, + ], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('passes when a DE residence is covered and an additional AT tax country is declared (extra multi-residency)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: true }), humanKyc()), + [ + { country: 'DE', tin: 'DE123456789' }, + { country: 'AT', tin: 'AT987654321' }, + ], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('N1: throws when a DE residence has only swissTaxResidence and no DE countryAndTINs entry', async () => { + const dto = await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: true }), humanKyc()); + // countryAndTINs intentionally omitted — DE address must still appear among tax residences + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(BadRequestException); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /Tax residence must include the residence country \(DE\)/, + ); + }); + + it('N2: throws when a DE residence is missing from the declared tax residences (only FR)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [{ country: 'FR', tin: 'FR111111111' }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(BadRequestException); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /Tax residence must include the residence country \(DE\)/, + ); + }); + + it('N3: throws when a CH residence is not covered (swissTaxResidence false, only FR)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: false }), humanKyc()), + [{ country: 'FR', tin: 'FR111111111' }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(BadRequestException); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /Tax residence must include the residence country \(CH\)/, + ); + }); + + it('N4: throws when countryAndTINs contains duplicate countries', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [ + { country: 'DE', tin: 'DE111' }, + { country: 'DE', tin: 'DE222' }, + ], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(BadRequestException); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /countryAndTINs must not contain duplicate countries/, + ); + }); + + it('N5: throws when CH appears in countryAndTINs (must use swissTaxResidence instead)', async () => { + // CH address covered only via countryAndTINs.CH would bypass the swissTaxResidence flag. + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: false }), humanKyc()), + [{ country: 'CH', tin: 'should-not-be-here' }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/countryAndTINs must not include CH/); + }); + + it('N6: throws when a multi-residence TIN entry has an empty tin (even with swissTaxResidence true)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: true }), humanKyc()), + [{ country: 'FR', tin: ' ' }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /countryAndTINs.tin must be a non-empty string/, + ); + }); + it('resolveSignedRegistrationMessage normalizes a signature that lacks the 0x prefix', async () => { const fields = humanFields(); const signature = await wallet._signTypedData(domain, types, fields); @@ -3150,36 +3300,214 @@ describe('RealUnitService', () => { }); }); - describe('completeRegistration — first-time customer with tax-residence TINs (countryAndTINs persistence)', () => { + describe('completeRegistration — tax-residence TINs (audit-safe user_data.tin persistence)', () => { + // Data rule: never overwrite a DB value if the previous value would become unrecoverable. + // - New non-empty countryAndTINs → written AFTER forwardRegistration (signedPayload holds the event) + // with a before→after audit log. + // - Swiss-only / empty countryAndTINs → does NOT clear an existing non-null user_data.tin. + // validateRegistrationDto is mocked here — tax-residence rule coverage lives in its own describe. + + const TIN_DE = { country: 'DE', tin: 'DE123456789' }; + const TIN_FR = { country: 'FR', tin: 'FR111111111' }; + const TIN_US = { country: 'US', tin: 'US999999999' }; + const STALE = JSON.stringify([{ country: 'XX', tin: 'stale' }]); + + type TinScenario = { + id: string; + addressCountry: string; + swissTaxResidence: boolean; + countryAndTINs: { country: string; tin: string }[] | undefined | []; + /** Expected next value when previous tin is empty; null means no tin column write. */ + expectedTinWhenEmpty: string | null; + }; + + const tinScenarios: TinScenario[] = [ + { + id: 'S1', + addressCountry: 'CH', + swissTaxResidence: true, + countryAndTINs: undefined, + expectedTinWhenEmpty: null, + }, + { + id: 'S1-empty', + addressCountry: 'CH', + swissTaxResidence: true, + countryAndTINs: [], + expectedTinWhenEmpty: null, + }, + { + id: 'S2', + addressCountry: 'DE', + swissTaxResidence: false, + countryAndTINs: [TIN_DE], + expectedTinWhenEmpty: JSON.stringify([TIN_DE]), + }, + { + id: 'S3', + addressCountry: 'CH', + swissTaxResidence: true, + countryAndTINs: [TIN_FR], + expectedTinWhenEmpty: JSON.stringify([TIN_FR]), + }, + { + id: 'S4', + addressCountry: 'DE', + swissTaxResidence: true, + countryAndTINs: [TIN_DE], + expectedTinWhenEmpty: JSON.stringify([TIN_DE]), + }, + { + id: 'S5', + addressCountry: 'DE', + swissTaxResidence: false, + countryAndTINs: [TIN_DE, TIN_FR, TIN_US], + expectedTinWhenEmpty: JSON.stringify([TIN_DE, TIN_FR, TIN_US]), + }, + ]; + + let forwardSpy: jest.SpyInstance; + beforeEach(() => { jest.spyOn(service as any, 'validateRegistrationDto').mockResolvedValue(undefined); jest .spyOn(service as any, 'findRegistration') .mockResolvedValue({ registration: undefined, isForCurrentWallet: false }); - jest.spyOn(service as any, 'forwardRegistration').mockResolvedValue(true); + forwardSpy = jest.spyOn(service as any, 'forwardRegistration').mockResolvedValue(true); + (service as any).countryService.getCountryWithSymbol.mockResolvedValue({ id: 1, symbol: 'CH' }); + (service as any).languageService.getLanguageBySymbol.mockResolvedValue({ id: 1, symbol: 'DE' }); + logService.create.mockResolvedValue({} as any); }); - it('serializes the provided countryAndTINs into the tin field before forwarding', async () => { - const dto: any = { - walletAddress: '0xabc', - signature: '0xsig', - email: 'max@example.com', - kycData: { accountType: 'Personal' }, - nationality: 'CH', - birthday: '1990-01-01', - lang: 'DE', - countryAndTINs: [{ country: 'DE', tin: '12345' }], + const buildDto = (scenario: TinScenario): any => ({ + walletAddress: '0xabc', + signature: '0xsig', + email: 'max@example.com', + kycData: { accountType: 'Personal' }, + nationality: scenario.addressCountry, + birthday: '1990-01-01', + lang: 'DE', + addressCountry: scenario.addressCountry, + swissTaxResidence: scenario.swissTaxResidence, + countryAndTINs: scenario.countryAndTINs, + }); + + const tinUpdates = (): any[] => + (userDataService.updateUserDataInternal as jest.Mock).mock.calls + .map((c) => c[1]) + .filter((u) => u && Object.prototype.hasOwnProperty.call(u, 'tin')); + + describe.each(tinScenarios)( + '$id first-time customer (empty previous tin) — addressCountry=$addressCountry', + (scenario) => { + it(`forwards first, then writes tin only when non-empty (expected=${ + scenario.expectedTinWhenEmpty === null ? 'no write' : 'JSON' + })`, async () => { + const dto = buildDto(scenario); + const userData: any = { + id: 1, + kycLevel: KycLevel.LEVEL_10, + mail: 'max@example.com', + firstname: null, + tin: null, + }; + userService.getUserByAddress.mockResolvedValue({ userData } as any); + + const status = await service.completeRegistration(1, dto); + + expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); + expect(userDataService.updatePersonalData).toHaveBeenCalledWith(userData, dto.kycData); + // Personal-data update never includes tin (tin is written only after forward). + const personalUpdate = (userDataService.updateUserDataInternal as jest.Mock).mock.calls[0][1]; + expect(personalUpdate).not.toHaveProperty('tin'); + expect(forwardSpy).toHaveBeenCalledWith(userData, dto); + + const tinWrites = tinUpdates(); + if (scenario.expectedTinWhenEmpty === null) { + expect(tinWrites).toHaveLength(0); + expect(logService.create).not.toHaveBeenCalledWith(expect.objectContaining({ subsystem: 'UserDataTin' })); + } else { + expect(tinWrites).toEqual([{ tin: scenario.expectedTinWhenEmpty }]); + expect(logService.create).toHaveBeenCalledWith( + expect.objectContaining({ + system: 'RealUnit', + subsystem: 'UserDataTin', + message: expect.stringContaining('"previousTin":null'), + }), + ); + // Audit before column write: log must be called before the tin update. + const logOrder = (logService.create as jest.Mock).mock.invocationCallOrder.find((_, i) => { + const arg = (logService.create as jest.Mock).mock.calls[i][0]; + return arg?.subsystem === 'UserDataTin'; + }); + const tinOrder = (userDataService.updateUserDataInternal as jest.Mock).mock.invocationCallOrder.find( + (_, i) => { + const arg = (userDataService.updateUserDataInternal as jest.Mock).mock.calls[i][1]; + return arg && Object.prototype.hasOwnProperty.call(arg, 'tin'); + }, + ); + expect(logOrder).toBeLessThan(tinOrder!); + } + }); + }, + ); + + describe.each(tinScenarios)( + '$id existing personal data with stale tin — addressCountry=$addressCountry', + (scenario) => { + it('never destroys the previous non-null tin without a recoverable replacement', async () => { + const dto = buildDto(scenario); + const userData: any = { + id: 1, + kycLevel: KycLevel.LEVEL_10, + mail: 'max@example.com', + firstname: 'Max', + tin: STALE, + }; + userService.getUserByAddress.mockResolvedValue({ userData } as any); + jest.spyOn(service as any, 'isPersonalDataMatching').mockReturnValue(true); + + const status = await service.completeRegistration(1, dto); + + expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); + expect(userDataService.updatePersonalData).not.toHaveBeenCalled(); + expect(forwardSpy).toHaveBeenCalledWith(userData, dto); + + const tinWrites = tinUpdates(); + if (scenario.expectedTinWhenEmpty === null) { + // Swiss-only must NOT clear STALE — that would lose data not on the new payload. + expect(tinWrites).toHaveLength(0); + } else { + // Non-empty next set: overwrite after audit (new value also on signedPayload). + expect(tinWrites).toEqual([{ tin: scenario.expectedTinWhenEmpty }]); + const audit = (logService.create as jest.Mock).mock.calls.find( + (c) => c[0]?.subsystem === 'UserDataTin', + )?.[0]; + expect(audit).toBeDefined(); + const body = JSON.parse(audit.message); + expect(body.previousTin).toBe(STALE); + expect(body.nextTin).toBe(scenario.expectedTinWhenEmpty); + } + }); + }, + ); + + it('fails closed: does not overwrite tin when the before→after audit log cannot be written', async () => { + const dto = buildDto(tinScenarios.find((s) => s.id === 'S2')!); + const userData: any = { + id: 1, + kycLevel: KycLevel.LEVEL_10, + mail: 'max@example.com', + firstname: 'Max', + tin: STALE, }; - const userData: any = { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', firstname: null }; userService.getUserByAddress.mockResolvedValue({ userData } as any); - (service as any).countryService.getCountryWithSymbol.mockResolvedValue({ id: 1, symbol: 'CH' }); - (service as any).languageService.getLanguageBySymbol.mockResolvedValue({ id: 1, symbol: 'DE' }); - - const status = await service.completeRegistration(1, dto); + jest.spyOn(service as any, 'isPersonalDataMatching').mockReturnValue(true); + logService.create.mockRejectedValue(new Error('log down')); - expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); - const [, update] = (userDataService.updateUserDataInternal as jest.Mock).mock.calls[0]; - expect(update.tin).toBe(JSON.stringify([{ country: 'DE', tin: '12345' }])); + await expect(service.completeRegistration(1, dto)).rejects.toThrow('log down'); + // Registration forward already ran, but the column must not change without audit. + expect(tinUpdates()).toHaveLength(0); }); }); diff --git a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts index 4c5f431041..4076b596d7 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts @@ -186,7 +186,17 @@ export class AktionariatRegistrationDto { @IsEnum(RealUnitLanguage) lang: RealUnitLanguage; - @ApiPropertyOptional({ type: [CountryAndTin], description: 'Required if swissTaxResidence is false' }) + @ApiPropertyOptional({ + type: [CountryAndTin], + description: + 'Tax residences with TINs for non-CH countries only (never CH — use swissTaxResidence). ' + + 'Required when swissTaxResidence is false. Multi-residence TINs may also be sent when ' + + 'swissTaxResidence is true; nested shape is enforced by the service. addressCountry must be ' + + 'covered: CH via swissTaxResidence, any other country via an entry here.', + }) + // Presence required only for non-Swiss. Nested shape for multi-residence with + // swissTaxResidence=true is enforced in RealUnitService.validateTaxResidenceCoversAddress + // (class-validator ValidateIf cannot both require-when-false and nested-check-when-present cleanly). @ValidateIf((o: AktionariatRegistrationDto) => !o.swissTaxResidence) @IsNotEmpty({ message: 'countryAndTINs is required when swissTaxResidence is false' }) @IsArray() diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index d6449e00f7..ac293c8719 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -745,6 +745,9 @@ export class RealUnitService { dto.walletAddress, ); if (isForCurrentWallet) { + // Registration row is already durable — still sync user_data.tin (e.g. retry after a + // previous attempt that forwarded successfully but failed the tin audit write). + await this.persistUserDataTinAfterRegistration(userData, dto); return this.idempotentRegistrationResult(userData, existingRegistration!, dto.signature); } @@ -754,21 +757,25 @@ export class RealUnitService { throw new BadRequestException('Personal data does not match existing data'); } - // save personal data + // Personal data first (first-time only). TINs are intentionally NOT written here: + // user_data.tin is only updated AFTER the registration row is durable so every change + // is recoverable (see persistUserDataTinAfterRegistration). if (!hasExistingData) { await this.userDataService.updatePersonalData(userData, dto.kycData); await this.userDataService.updateUserDataInternal(userData, { nationality: await this.countryService.getCountryWithSymbol(dto.nationality), birthday: new Date(dto.birthday), language: dto.lang && (await this.languageService.getLanguageBySymbol(dto.lang)), - tin: dto.countryAndTINs?.length ? JSON.stringify(dto.countryAndTINs) : undefined, }); } - // forward to Aktionariat (persists the single-source-of-truth registration row in both branches) + // forward to Aktionariat (persists the single-source-of-truth registration row in both branches). + // signedPayload carries countryAndTINs for this event; superseded rows stay queryable. const success = await this.forwardRegistration(userData, dto); if (!success) return RealUnitRegistrationStatus.FORWARDING_FAILED; + await this.persistUserDataTinAfterRegistration(userData, dto); + return RealUnitRegistrationStatus.COMPLETED; } @@ -885,6 +892,103 @@ export class RealUnitService { } } + // Residence country (addressCountry) must appear among the declared tax residences. + // CH is covered ONLY by `swissTaxResidence === true` — never via countryAndTINs (CH has no + // TIN in this contract). Non-CH countries are covered by a countryAndTINs entry with a + // non-empty TIN. Additional tax countries beyond the address country are allowed. + private validateTaxResidenceCoversAddress(dto: RealUnitRegistrationDto): void { + const entries = dto.countryAndTINs ?? []; + + if (entries.some((e) => e.country === 'CH')) { + throw new BadRequestException( + 'countryAndTINs must not include CH; set swissTaxResidence for Swiss tax residence', + ); + } + + // Nested shape is also enforced here so multi-residence entries stay valid when + // swissTaxResidence is true (DTO @ValidateIf historically skipped nested checks then). + for (const entry of entries) { + if (typeof entry.country !== 'string' || !/^[A-Z]{2}$/.test(entry.country)) { + throw new BadRequestException('countryAndTINs.country must be a 2-letter country code'); + } + if (typeof entry.tin !== 'string' || !entry.tin.trim()) { + throw new BadRequestException('countryAndTINs.tin must be a non-empty string'); + } + } + + const tinCountries = entries.map((e) => e.country); + if (new Set(tinCountries).size !== tinCountries.length) { + throw new BadRequestException('countryAndTINs must not contain duplicate countries'); + } + + if (!dto.swissTaxResidence && entries.length === 0) { + throw new BadRequestException('countryAndTINs is required when swissTaxResidence is false'); + } + + const taxCountries = new Set(tinCountries); + if (dto.swissTaxResidence) taxCountries.add('CH'); + + if (!taxCountries.has(dto.addressCountry)) { + throw new BadRequestException(`Tax residence must include the residence country (${dto.addressCountry})`); + } + } + + // Canonical shape for user_data.tin: JSON array of {country, tin}, or null when the + // submission carries no non-CH tax residences (Swiss-only / empty). + private serializeCountryAndTins(entries: { country: string; tin: string }[] | undefined): string | null { + return entries?.length ? JSON.stringify(entries) : null; + } + + // DFX data rule: overwriting a DB value is only allowed when the previous value remains + // recoverable. Tax residences are therefore dual-stored: + // 1) aktionariat_registration.signedPayload — immutable event log (superseded rows kept) + // 2) user_data.tin — current snapshot for queries + // This method runs ONLY after forwardRegistration has persisted (1). It never silently + // destroys a non-null previous tin by writing null (Swiss-only submissions omit + // countryAndTINs from the new payload, so nulling would lose prior foreign TINs). + // Every actual mutation is audited with before→after before the column is updated. + private async persistUserDataTinAfterRegistration(userData: UserData, dto: RealUnitRegistrationDto): Promise { + const previousTin = userData.tin ?? null; + const nextTin = this.serializeCountryAndTins(dto.countryAndTINs); + + if (previousTin === nextTin) return; + + // Never clear a stored TIN set to null — that would drop recoverable history from + // user_data without placing those TINs on the new registration row (Swiss-only payload). + // Prior foreign TINs stay on user_data until a new non-empty set is submitted; each + // registration event remains on aktionariat_registration (active + superseded). + if (nextTin === null && previousTin != null) { + return; + } + + // Fail closed on audit: do not overwrite until the before→after event is written. + await this.logUserDataTinChange(userData.id, dto.walletAddress, previousTin, nextTin); + await this.userDataService.updateUserDataInternal(userData, { tin: nextTin }); + } + + private async logUserDataTinChange( + userDataId: number, + walletAddress: string, + previousTin: string | null, + nextTin: string | null, + ): Promise { + await this.logService.create({ + system: 'RealUnit', + subsystem: 'UserDataTin', + severity: LogSeverity.INFO, + message: JSON.stringify({ + action: 'user_data.tin change', + userDataId, + walletAddress, + previousTin, + nextTin, + changedAt: new Date().toISOString(), + }), + category: String(userDataId), + valid: null, + }); + } + private async validateRegistrationDto(dto: RealUnitRegistrationDto): Promise { // signature validation if (!this.verifyRealUnitRegistrationSignature(dto)) { @@ -903,6 +1007,13 @@ export class RealUnitService { maxAge.setFullYear(maxAge.getFullYear() - 140); if (birthday < maxAge) throw new BadRequestException('Birthday cannot be more than 140 years ago'); + // Tax residence must cover the residence (address) country. `swissTaxResidence` + // counts as CH; each `countryAndTINs` entry covers its country code. Multi- + // residence is allowed (additional countries beyond the address country), but + // the address country itself is mandatory among the declared tax residences — + // e.g. living in DE requires a DE tax-residence entry (with TIN). + this.validateTaxResidenceCoversAddress(dto); + // data validation if (dto.kycData.accountType === AccountType.ORGANIZATION) { if (dto.type !== RealUnitUserType.CORPORATION) { @@ -1167,8 +1278,10 @@ export class RealUnitService { addressPostalCode: userData.zip ?? '', addressCity: userData.location ?? '', addressCountry: userData.country?.symbol ?? '', - // Swiss tax residence cannot be derived from KYC data alone; default to the country-of-residence - // signal so a CH-resident pre-fills the common case. The user can still override before signing. + // Default Swiss tax residence from the country-of-residence signal so a CH-resident + // pre-fills the common case. The signed payload must still cover addressCountry among + // the declared tax residences (swissTaxResidence and/or countryAndTINs) — see + // validateTaxResidenceCoversAddress. swissTaxResidence: userData.country?.symbol === 'CH', lang: lang ?? RealUnitLanguage.EN, countryAndTINs: tinEntries.length ? tinEntries : undefined, From b864237dd189ada94dcfbe2465726ab4a9872519 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 14 Jul 2026 18:09:35 +0200 Subject: [PATCH 7/9] feat(country): enable Philippines onboarding (#4206) * feat(country): enable Philippines onboarding * test(country): harden Philippines onboarding migration --- ...84029705806-EnablePhilippinesOnboarding.js | 27 ++++ ...e-philippines-onboarding.migration.spec.ts | 118 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 migration/1784029705806-EnablePhilippinesOnboarding.js create mode 100644 src/shared/models/country/__tests__/enable-philippines-onboarding.migration.spec.ts diff --git a/migration/1784029705806-EnablePhilippinesOnboarding.js b/migration/1784029705806-EnablePhilippinesOnboarding.js new file mode 100644 index 0000000000..37ae62df29 --- /dev/null +++ b/migration/1784029705806-EnablePhilippinesOnboarding.js @@ -0,0 +1,27 @@ +// Enable individual DFX onboarding for the Philippines. +// +// The Philippines is no longer subject to FATF increased monitoring and its country row is already +// FATF-enabled. The remaining dfxEnable=false flag keeps it out of the DFX KYC country list and also +// makes bankAllowed false in the public country DTO. Keep organization onboarding and all unrelated +// country controls unchanged. This regulatory allow-list change is intentionally one-way: reverting an +// application deployment must not silently block the country again. A future restriction requires its +// own explicit migration and compliance review. + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +module.exports = class EnablePhilippinesOnboarding1784029705806 { + name = 'EnablePhilippinesOnboarding1784029705806'; + + async up(queryRunner) { + await queryRunner.query( + `UPDATE "country" SET "dfxEnable" = true, "updated" = NOW() WHERE "symbol" = 'PH' AND "dfxEnable" = false`, + ); + } + + async down(_queryRunner) { + // Intentionally empty: country restrictions are compliance data and must be changed explicitly. + } +}; diff --git a/src/shared/models/country/__tests__/enable-philippines-onboarding.migration.spec.ts b/src/shared/models/country/__tests__/enable-philippines-onboarding.migration.spec.ts new file mode 100644 index 0000000000..1bc1e73388 --- /dev/null +++ b/src/shared/models/country/__tests__/enable-philippines-onboarding.migration.spec.ts @@ -0,0 +1,118 @@ +import { IMemoryTable, newDb } from 'pg-mem'; + +type CountryRow = { + symbol: string; + dfxEnable: boolean; + dfxOrganizationEnable: boolean; + fatfEnable: boolean; + updated: Date; +}; + +let EnablePhilippinesOnboarding: new () => { + up(queryRunner: { query(sql: string): Promise }): Promise; + down(queryRunner: { query(sql: string): Promise }): Promise; +}; + +describe('EnablePhilippinesOnboarding migration', () => { + let db: ReturnType; + let query: jest.Mock, [string]>; + let migration: InstanceType; + + beforeAll(() => { + // The migration is intentionally a plain CommonJS module, matching TypeORM's runtime loader. + // eslint-disable-next-line @typescript-eslint/no-require-imports + EnablePhilippinesOnboarding = require('../../../../../migration/1784029705806-EnablePhilippinesOnboarding'); + }); + + beforeEach(() => { + db = newDb(); + db.public.none(` + CREATE TABLE "country" ( + "symbol" character varying(10) PRIMARY KEY, + "dfxEnable" boolean NOT NULL, + "dfxOrganizationEnable" boolean NOT NULL, + "fatfEnable" boolean NOT NULL, + "updated" TIMESTAMP NOT NULL + ) + `); + + query = jest.fn(async (sql: string) => db.public.none(sql)); + migration = new EnablePhilippinesOnboarding(); + }); + + const insertCountry = ( + symbol: string, + dfxEnable: boolean, + dfxOrganizationEnable = false, + fatfEnable = true, + ): void => { + getCountryTable().insert({ + symbol, + dfxEnable, + dfxOrganizationEnable, + fatfEnable, + updated: new Date('2024-01-01T00:00:00.000Z'), + }); + }; + + const getCountryTable = () => db.public.getTable('country') as IMemoryTable; + + const getCountry = (symbol: string): CountryRow => getCountryTable().find({ symbol })[0]; + + it('enables only individual DFX onboarding for PH and is idempotent', async () => { + insertCountry('PH', false); + insertCountry('CH', false, true, false); + + await migration.up({ query }); + + const enabled = getCountry('PH'); + expect(enabled).toMatchObject({ + symbol: 'PH', + dfxEnable: true, + dfxOrganizationEnable: false, + fatfEnable: true, + }); + expect(enabled.updated).not.toEqual(new Date('2024-01-01T00:00:00.000Z')); + expect(getCountry('CH')).toMatchObject({ + dfxEnable: false, + dfxOrganizationEnable: true, + fatfEnable: false, + updated: new Date('2024-01-01T00:00:00.000Z'), + }); + + await migration.up({ query }); + + expect(getCountry('PH')).toEqual(enabled); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('does not rewrite an already enabled PH row', async () => { + insertCountry('PH', true); + + await migration.up({ query }); + + expect(getCountry('PH')).toMatchObject({ + dfxEnable: true, + updated: new Date('2024-01-01T00:00:00.000Z'), + }); + }); + + it('is a safe no-op when the PH row does not exist yet', async () => { + insertCountry('CH', true, true, true); + + await expect(migration.up({ query })).resolves.toBeUndefined(); + + expect(getCountryTable().find()).toEqual([getCountry('CH')]); + }); + + it('does not silently reintroduce the restriction during rollback', async () => { + insertCountry('PH', false); + await migration.up({ query }); + query.mockClear(); + + await migration.down({ query }); + + expect(getCountry('PH').dfxEnable).toBe(true); + expect(query).not.toHaveBeenCalled(); + }); +}); From 21db1a580f8973394ea11087f8f1b25e845fded7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:12:17 +0200 Subject: [PATCH 8/9] fix(aml): distinguish Scorechain provider failure from real high-risk hit (#4207) * fix(aml): distinguish Scorechain provider failure from real high-risk hit Screening failures (provider 5xx, timeout, quota, misconfiguration) were reduced to the same boolean as a real high-risk hit and surfaced as AmlError.SCORECHAIN_HIGH_RISK, so compliance saw Scorechain hits for transactions that were never screened (no screening row, no PDF). Replace the boolean channel with a ScorechainOutcome enum (Pass/HighRisk/Unavailable) and add AmlError.SCORECHAIN_UNAVAILABLE. Both outcomes stay fail-closed (CRUCIAL -> PENDING / generic ManualCheck, no tipping-off), but the internal classification is now distinct. Closes #4205 * docs(aml): scope the ScorechainUnavailable rationale to the actual failure paths A missing risk threshold makes isHighRisk throw only after the screening row and the compliance PDF have been persisted, so the blanket claim that neither exists was wrong for that path. Also drop a tautological assertion from both preparation specs. * docs(aml): keep provider/infrastructure on one line in the Scorechain spec A soft wrap right after the slash rendered as "provider/ infrastructure". --- docs/specs/scorechain-integration.md | 25 +++++++++++-- .../core/aml/enums/aml-error.enum.ts | 11 ++++++ .../core/aml/enums/scorechain-outcome.enum.ts | 16 ++++++++ .../__tests__/aml-helper.service.spec.ts | 37 ++++++++++++++----- .../core/aml/services/aml-helper.service.ts | 14 ++++--- .../__tests__/buy-crypto.entity.spec.ts | 29 ++++++++++++--- .../process/entities/buy-crypto.entity.ts | 16 +++++--- .../buy-crypto-preparation.service.spec.ts | 31 +++++++++++----- .../buy-crypto-preparation.service.ts | 20 ++++++---- .../process/__tests__/buy-fiat.entity.spec.ts | 29 ++++++++++++--- .../sell-crypto/process/buy-fiat.entity.ts | 16 +++++--- .../buy-fiat-preparation.service.spec.ts | 29 +++++++++++---- .../services/buy-fiat-preparation.service.ts | 20 ++++++---- 13 files changed, 219 insertions(+), 74 deletions(-) create mode 100644 src/subdomains/core/aml/enums/scorechain-outcome.enum.ts diff --git a/docs/specs/scorechain-integration.md b/docs/specs/scorechain-integration.md index dec0c43d7e..8647b83ea1 100644 --- a/docs/specs/scorechain-integration.md +++ b/docs/specs/scorechain-integration.md @@ -367,6 +367,19 @@ and consistent — no untyped failures, no silent passes: it must never be returned to customers or partner webhooks; only Support/Compliance read it (e.g. the `RoleGuard(SUPPORT)`-gated support-issue data endpoint). Do not add a Scorechain-named `AmlReason` and do not surface `comment` in any customer-facing DTO. + + **HighRisk vs. Unavailable (DFXswiss/api#4205):** a real Scorechain hit and a + provider/infrastructure failure (5xx, timeout, quota reached, misconfiguration, + unexpected throw) are different events and must not collapse into the same classification — + a Prod incident showed a Buy-Crypto tx routed to manual review with comment + `ScorechainHighRisk` although Scorechain had returned HTTP 500 and no + `scorechain_screening` row or compliance PDF ever existed for it. The + call-site now returns a `ScorechainOutcome` (`PASS` / `HIGH_RISK` / `UNAVAILABLE`, + `scorechain-outcome.enum.ts`) instead of a boolean; `HIGH_RISK` still maps to + `AmlError.SCORECHAIN_HIGH_RISK`, and the new `AmlError.SCORECHAIN_UNAVAILABLE` (mapped + identically: `{ type: CRUCIAL, amlCheck: PENDING, amlReason: MANUAL_CHECK }`) covers the failure + case. Customer-facing fail-closed behaviour is unchanged (both still route to manual review); + only the internal classification — visible in `comment` and stats — is now accurate. 2. **TMS depth:** the **synchronous `scoringAnalysis` gate is the only implemented flow**. The async TMS workflow (`register*` → scenarios/alerts) is a license-gated feature (`NotIncludedInLicense`) that DFX does **not** license. Its placeholder client methods, the @@ -381,10 +394,14 @@ and consistent — no untyped failures, no silent passes: `SCORECHAIN_MONTHLY_CHECK_LIMIT` (or a provider/transport error) throws `ServiceUnavailableException` inside the screening service. The AML call-site (`screenScorechain` in `buy-crypto-preparation` / `buy-fiat-preparation`) catches it and - returns high risk, so the tx is routed to manual review (`AmlError.SCORECHAIN_HIGH_RISK` → - `CheckStatus.PENDING`) instead of throwing out of the AML computation. Letting the throw escape - the compute path would silently stall settlement of every otherwise-passing tx on every cron - run, so the error is deliberately caught at the seam. + returns `ScorechainOutcome.UNAVAILABLE`, so the tx is routed to manual review + (`AmlError.SCORECHAIN_UNAVAILABLE` → `CheckStatus.PENDING`) instead of throwing out of the AML + computation — deliberately **not** `SCORECHAIN_HIGH_RISK`, since no risk verdict could be + established: for a transport/quota error neither a `scorechain_screening` row nor a compliance + PDF exists, but for a missing risk-threshold configuration both may already have been + persisted before the throw. Letting the throw escape the compute path would + silently stall settlement of every otherwise-passing tx on every cron run, so the error is + deliberately caught at the seam. 5. **ikna coexistence:** run both fully in parallel (no routing rule); Scorechain is additive. 6. **Live pipeline injection (call-sites):** the async screening is wired into the synchronous AML pipeline (`screenScorechain` is threaded through `amlCheckAndFillUp` for every buy/sell that diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index 5961fd2982..1ce05e5052 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -71,6 +71,7 @@ export enum AmlError { BANK_TX_CUSTOMER_NAME_MISSING = 'BankTxCustomerNameMissing', FORCE_MANUAL_CHECK = 'ForceManualCheck', SCORECHAIN_HIGH_RISK = 'ScorechainHighRisk', + SCORECHAIN_UNAVAILABLE = 'ScorechainUnavailable', ASSET_INPUT_NOT_ALLOWED = 'AssetInputNotAllowed', REFERRAL_NO_TRADE_HISTORY = 'ReferralNoTradeHistory', } @@ -386,6 +387,16 @@ export const AmlErrorResult: { amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK, }, + // Screening yielded no usable verdict (provider 5xx/timeout, quota reached, misconfiguration): fail-closed + // to manual review, but NOT a Scorechain hit. Depending on the cause there is either no screening row and + // no compliance PDF at all (transport/quota error), or a persisted row whose score could not be evaluated + // (missing risk threshold) — never a scored hit. + // amlReason stays the generic MANUAL_CHECK (no tipping-off, same public story as any other manual hold). + [AmlError.SCORECHAIN_UNAVAILABLE]: { + type: AmlErrorType.CRUCIAL, + amlCheck: CheckStatus.PENDING, + amlReason: AmlReason.MANUAL_CHECK, + }, [AmlError.ASSET_INPUT_NOT_ALLOWED]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.FAIL, diff --git a/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts b/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts new file mode 100644 index 0000000000..a03dd7c0e0 --- /dev/null +++ b/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts @@ -0,0 +1,16 @@ +import { AmlError } from 'src/subdomains/core/aml/enums/aml-error.enum'; + +// Outcome of the Scorechain on-chain screening gate, as consumed by the AML computation. +// PASS also covers "no signal" (Scorechain disabled, unconfigured, unsupported chain, no object id): +// the tx is then decided by the other AML mechanisms, exactly as before. +export enum ScorechainOutcome { + PASS = 'Pass', + HIGH_RISK = 'HighRisk', + UNAVAILABLE = 'Unavailable', +} + +export const ScorechainOutcomeError: { [o in ScorechainOutcome]: AmlError | null } = { + [ScorechainOutcome.PASS]: null, + [ScorechainOutcome.HIGH_RISK]: AmlError.SCORECHAIN_HIGH_RISK, + [ScorechainOutcome.UNAVAILABLE]: AmlError.SCORECHAIN_UNAVAILABLE, +}; diff --git a/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts b/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts index a1223e620e..cef5a3a055 100644 --- a/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts +++ b/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts @@ -40,6 +40,7 @@ import { TestUtil } from 'src/shared/utils/test.util'; import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { AmlRule } from 'src/subdomains/core/aml/enums/aml-rule.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { KycLevel, KycType } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; @@ -49,7 +50,7 @@ describe('AmlHelperService - Scorechain gate', () => { // Minimal entity: getAmlErrors is stubbed, so only the fields getAmlResult itself reads matter. const entity = { amlCheck: null, created: new Date(0) } as any; - const getAmlResult = (scorechainHighRisk: boolean) => + const getAmlResult = (scorechainOutcome: ScorechainOutcome) => AmlHelperService.getAmlResult( entity, null as any, // inputAsset @@ -68,27 +69,37 @@ describe('AmlHelperService - Scorechain gate', () => { undefined, // ipLogCountries undefined, // virtualIban undefined, // multiAccountBankNames - scorechainHighRisk, + scorechainOutcome, ); - it('forwards scorechainHighRisk to getAmlErrors as the last argument', () => { + it('forwards scorechainOutcome to getAmlErrors as the last argument', () => { const spy = jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([]); - getAmlResult(true); + getAmlResult(ScorechainOutcome.HIGH_RISK); - expect(spy.mock.calls[0].at(-1)).toBe(true); + expect(spy.mock.calls[0].at(-1)).toBe(ScorechainOutcome.HIGH_RISK); }); it('resolves a SCORECHAIN_HIGH_RISK error to a PENDING manual-review check (after the 10-min grace)', () => { jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([AmlError.SCORECHAIN_HIGH_RISK]); - const result = getAmlResult(true); + const result = getAmlResult(ScorechainOutcome.HIGH_RISK); expect(result.amlCheck).toBe(CheckStatus.PENDING); expect(result.amlReason).toBe(AmlReason.MANUAL_CHECK); expect(result.comment).toContain('ScorechainHighRisk'); }); + it('resolves a SCORECHAIN_UNAVAILABLE error to a PENDING manual-review check (after the 10-min grace)', () => { + jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([AmlError.SCORECHAIN_UNAVAILABLE]); + + const result = getAmlResult(ScorechainOutcome.UNAVAILABLE); + + expect(result.amlCheck).toBe(CheckStatus.PENDING); + expect(result.amlReason).toBe(AmlReason.MANUAL_CHECK); + expect(result.comment).toContain('ScorechainUnavailable'); + }); + describe('getAmlErrors Scorechain branch (real call)', () => { beforeAll(async () => { await Test.createTestingModule({ providers: [TestUtil.provideConfig()] }).compile(); @@ -143,7 +154,7 @@ describe('AmlHelperService - Scorechain gate', () => { const inputAsset = createCustomAsset({ name: 'BTC', amlRuleFrom: AmlRule.DEFAULT, sellable: true }); const ibanCountry = { symbol: 'DE', fatfEnable: true, amlRule: AmlRule.DEFAULT } as any; - const collect = (scorechainHighRisk: boolean): AmlError[] => + const collect = (scorechainOutcome: ScorechainOutcome): AmlError[] => AmlHelperService.getAmlErrors( cleanEntity(), inputAsset, @@ -162,15 +173,21 @@ describe('AmlHelperService - Scorechain gate', () => { undefined, // virtualIban undefined, // multiAccountBankNames undefined, // recommender - scorechainHighRisk, + scorechainOutcome, ); it('adds SCORECHAIN_HIGH_RISK when the screening is high-risk', () => { - expect(collect(true)).toContain(AmlError.SCORECHAIN_HIGH_RISK); + expect(collect(ScorechainOutcome.HIGH_RISK)).toContain(AmlError.SCORECHAIN_HIGH_RISK); }); it('adds no Scorechain error when the screening is clean', () => { - expect(collect(false)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK); + expect(collect(ScorechainOutcome.PASS)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK); + expect(collect(ScorechainOutcome.PASS)).not.toContain(AmlError.SCORECHAIN_UNAVAILABLE); + }); + + it('adds SCORECHAIN_UNAVAILABLE (not SCORECHAIN_HIGH_RISK) when screening was unavailable', () => { + expect(collect(ScorechainOutcome.UNAVAILABLE)).toContain(AmlError.SCORECHAIN_UNAVAILABLE); + expect(collect(ScorechainOutcome.UNAVAILABLE)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK); }); }); }); diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 58b5b2d2be..c5df834318 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -30,6 +30,7 @@ import { AmlError, AmlErrorResult, AmlErrorType, DelayResultError } from '../enu import { AmlReason, KycAmlReasons, RecheckAmlReasons } from '../enums/aml-reason.enum'; import { AmlRule, SpecialIpCountries } from '../enums/aml-rule.enum'; import { CheckStatus } from '../enums/check-status.enum'; +import { ScorechainOutcome, ScorechainOutcomeError } from '../enums/scorechain-outcome.enum'; export class AmlHelperService { static getAmlErrors( @@ -50,7 +51,7 @@ export class AmlHelperService { virtualIban?: VirtualIban, multiAccountBankNames?: string[], recommender?: UserData, - scorechainHighRisk = false, + scorechainOutcome: ScorechainOutcome = ScorechainOutcome.PASS, ): AmlError[] { const errors: AmlError[] = []; const nationality = entity.userData.nationality; @@ -62,8 +63,11 @@ export class AmlHelperService { return errors; // Scorechain on-chain screening (withdrawal target address / deposit tx); the async call runs in - // the AML orchestrator and is reduced to this boolean. Outcome layer only (CRUCIAL → manual review). - if (scorechainHighRisk) errors.push(AmlError.SCORECHAIN_HIGH_RISK); + // the AML orchestrator. HIGH_RISK is a real screening hit; UNAVAILABLE means screening was + // impossible (provider outage/timeout/quota/misconfiguration) — both are CRUCIAL → manual review, + // but they must stay distinguishable (see ScorechainOutcomeError / AmlErrorResult). + const scorechainError = ScorechainOutcomeError[scorechainOutcome]; + if (scorechainError) errors.push(scorechainError); if (isAsset(inputAsset) && inputAsset.name === 'REALU') errors.push(AmlError.ASSET_INPUT_NOT_ALLOWED); @@ -615,7 +619,7 @@ export class AmlHelperService { ipLogCountries?: string[], virtualIban?: VirtualIban, multiAccountBankNames?: string[], - scorechainHighRisk = false, + scorechainOutcome: ScorechainOutcome = ScorechainOutcome.PASS, ): { bankData?: BankData; amlCheck?: CheckStatus; @@ -642,7 +646,7 @@ export class AmlHelperService { virtualIban, multiAccountBankNames, recommender, - scorechainHighRisk, + scorechainOutcome, ).filter((e) => e); const comment = Array.from(new Set(amlErrors)).join(';'); diff --git a/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts b/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts index 58361b57ec..1903e56c90 100644 --- a/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts +++ b/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { TestUtil } from 'src/shared/utils/test.util'; import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlHelperService } from 'src/subdomains/core/aml/services/aml-helper.service'; import { LiquidityManagementPipelineStatus } from 'src/subdomains/core/liquidity-management/enums'; import { Price, PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; @@ -601,7 +602,7 @@ describe('BuyCrypto', () => { describe('#amlCheckAndFillUp Scorechain gate', () => { afterEach(() => jest.restoreAllMocks()); - const run = (entity: BuyCrypto, screen?: () => Promise) => + const run = (entity: BuyCrypto, screen?: () => Promise) => entity.amlCheckAndFillUp( null as any, // inputAsset 0, // minVolume @@ -625,7 +626,7 @@ describe('BuyCrypto', () => { it('does not screen when the tx would not otherwise pass', async () => { jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.FAIL } as any); - const screen = jest.fn().mockResolvedValue(true); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK); await run(createDefaultBuyCrypto(), screen); @@ -638,21 +639,21 @@ describe('BuyCrypto', () => { .spyOn(AmlHelperService, 'getAmlResult') .mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any) .mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any); - const screen = jest.fn().mockResolvedValue(true); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK); const entity = createDefaultBuyCrypto(); await run(entity, screen); expect(screen).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(2); - expect(spy.mock.calls[0].at(-1)).toBe(false); // phase 1: scorechainHighRisk=false - expect(spy.mock.calls[1].at(-1)).toBe(true); // phase 2: scorechainHighRisk=true + expect(spy.mock.calls[0].at(-1)).toBe(ScorechainOutcome.PASS); // phase 1 + expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.HIGH_RISK); // phase 2 expect(entity.amlCheck).toBe(CheckStatus.PENDING); }); it('keeps PASS when the tx would pass and screening is clean (no phase 2)', async () => { jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.PASS } as any); - const screen = jest.fn().mockResolvedValue(false); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.PASS); const entity = createDefaultBuyCrypto(); await run(entity, screen); @@ -669,6 +670,22 @@ describe('BuyCrypto', () => { expect(AmlHelperService.getAmlResult).toHaveBeenCalledTimes(1); }); + + it('screens when the tx would otherwise pass and flips to PENDING when the provider is unavailable', async () => { + const spy = jest + .spyOn(AmlHelperService, 'getAmlResult') + .mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any) + .mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.UNAVAILABLE); + const entity = createDefaultBuyCrypto(); + + await run(entity, screen); + + expect(screen).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.UNAVAILABLE); + expect(entity.amlCheck).toBe(CheckStatus.PENDING); + }); }); describe('#resetAmlCheck()', () => { diff --git a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts index 483080e3bd..58c72a1181 100644 --- a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts +++ b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts @@ -39,6 +39,7 @@ import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricin import { Column, Entity, Index, JoinColumn, ManyToOne, OneToOne } from 'typeorm'; import { AmlReason } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; +import { ScorechainOutcome } from '../../../aml/enums/scorechain-outcome.enum'; import { Buy } from '../../routes/buy/buy.entity'; import { BuyCryptoBatch } from './buy-crypto-batch.entity'; import { BuyCryptoFee } from './buy-crypto-fees.entity'; @@ -682,9 +683,9 @@ export class BuyCrypto extends IEntity { ipLogCountries?: string[], virtualIban?: VirtualIban, multiAccountBankNames?: string[], - screenScorechain?: () => Promise, + screenScorechain?: () => Promise, ): Promise> { - const computeAmlResult = (scorechainHighRisk: boolean) => + const computeAmlResult = (scorechainOutcome: ScorechainOutcome) => AmlHelperService.getAmlResult( this, inputAsset, @@ -703,13 +704,16 @@ export class BuyCrypto extends IEntity { ipLogCountries, virtualIban, multiAccountBankNames, - scorechainHighRisk, + scorechainOutcome, ); - let amlResult = computeAmlResult(false); + let amlResult = computeAmlResult(ScorechainOutcome.PASS); + // Run the (billable) Scorechain screening only when the tx would otherwise PASS. - if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS && (await screenScorechain())) - amlResult = computeAmlResult(true); + if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS) { + const scorechainOutcome = await screenScorechain(); + if (scorechainOutcome !== ScorechainOutcome.PASS) amlResult = computeAmlResult(scorechainOutcome); + } const update: Partial = { ...amlResult, diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index 3aad880471..b2b74efcd1 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -11,6 +11,7 @@ import * as processServiceModule from 'src/shared/services/process.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { ScorechainDocumentService } from 'src/subdomains/generic/kyc/services/scorechain-document.service'; @@ -119,7 +120,7 @@ describe('BuyCryptoPreparationService', () => { } describe('screenScorechain (Scorechain AML gate)', () => { - const call = (entity: any): Promise => (service as any).screenScorechain(entity); + const call = (entity: any): Promise => (service as any).screenScorechain(entity); let apiKeyBackup: string | undefined; @@ -151,7 +152,7 @@ describe('BuyCryptoPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenWithdrawalAddress').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(true); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.HIGH_RISK); expect(scorechainScreeningService.screenWithdrawalAddress).toHaveBeenCalledWith(Blockchain.ETHEREUM, '0xabc'); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -163,7 +164,7 @@ describe('BuyCryptoPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(false); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).toHaveBeenCalledWith(Blockchain.BITCOIN, 'txhash'); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -175,7 +176,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('addr'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -186,7 +187,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue(undefined); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -198,7 +199,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('0xabc'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -210,11 +211,11 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('0xabc'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); - it('fails closed to manual review (true) when the provider throws (outage / quota reached)', async () => { + it('fails closed to manual review (UNAVAILABLE) when the provider throws (outage / quota reached)', async () => { const entity = createCustomBuyCrypto({ cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); @@ -222,7 +223,19 @@ describe('BuyCryptoPreparationService', () => { .spyOn(scorechainScreeningService, 'screenDepositTransaction') .mockRejectedValue(new Error('scorechain unavailable')); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); + }); + + it('fails closed to UNAVAILABLE when isHighRisk throws (misconfigured risk threshold)', async () => { + const entity = createCustomBuyCrypto({ + cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, + }); + jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); + jest.spyOn(scorechainScreeningService, 'isHighRisk').mockImplementation(() => { + throw new Error('SCORECHAIN_RISK_THRESHOLD is not configured'); + }); + + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); }); it('stores a compliance report for a freshly-screened tx tied to a customer', async () => { diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index ec7fed3d85..b33053e2cc 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -13,6 +13,7 @@ import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AmountType, Util } from 'src/shared/utils/util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { BlockAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; @@ -70,17 +71,17 @@ export class BuyCryptoPreparationService { // does not cover yield no signal (the other AML mechanisms apply). isHighRisk is fail-closed for // deposits (invalid signature / no coverage / unsupported → high risk); a withdrawal to an address // with no coverage passes, since a fresh destination address has no data to assess. - private async screenScorechain(entity: BuyCrypto): Promise { + private async screenScorechain(entity: BuyCrypto): Promise { // Feature gate / kill-switch: when Scorechain is disabled or unconfigured (no API key), emit no // signal so the tx is decided by the other AML mechanisms. This is the deliberate off-state and // must never route an unscreened-because-off tx to manual review. - if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return false; + if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return ScorechainOutcome.PASS; const [blockchain, objectId, isDeposit] = entity.cryptoInput ? [entity.cryptoInput.asset.blockchain, entity.cryptoInput.inTxId, true] : [entity.outputAsset.blockchain, entity.targetAddress, false]; - if (!objectId || !toScorechainBlockchain(blockchain)) return false; + if (!objectId || !toScorechainBlockchain(blockchain)) return ScorechainOutcome.PASS; try { const screening = isDeposit @@ -92,13 +93,18 @@ export class BuyCryptoPreparationService { if (screening.isNewlyScreened && entity.userData) await this.scorechainDocumentService.createScreeningReport(entity.userData, screening); - return this.scorechainScreeningService.isHighRisk(screening); + return this.scorechainScreeningService.isHighRisk(screening) + ? ScorechainOutcome.HIGH_RISK + : ScorechainOutcome.PASS; } catch (e) { // Fail-closed to manual review: a provider/transport error or a reached monthly quota must not // throw out of the AML computation (which would silently stall settlement of every otherwise- - // passing tx on every cron run). Treat it as high risk → SCORECHAIN_HIGH_RISK → PENDING. - this.logger.error(`Scorechain screening failed for buy-crypto ${entity.id}, routing to manual review:`, e); - return true; + // passing tx on every cron run). + // Classify as UNAVAILABLE, never HIGH_RISK: no risk verdict could be established. A transport or quota + // error leaves no screening row and no compliance PDF at all; a missing risk threshold throws only + // after both have been persisted. In neither case may the tx be recorded as an actual Scorechain hit. + this.logger.error(`Scorechain screening unavailable for buy-crypto ${entity.id}, routing to manual review:`, e); + return ScorechainOutcome.UNAVAILABLE; } } diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts index 3fc0b09435..2b46f63b41 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts @@ -4,6 +4,7 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { TestUtil } from 'src/shared/utils/test.util'; import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlHelperService } from 'src/subdomains/core/aml/services/aml-helper.service'; import { createCustomFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; @@ -124,7 +125,7 @@ describe('BuyFiat entity', () => { }); afterEach(() => jest.restoreAllMocks()); - const run = (entity: any, screen?: () => Promise) => + const run = (entity: any, screen?: () => Promise) => entity.amlCheckAndFillUp( null, // inputAsset 0, // minVolume @@ -143,7 +144,7 @@ describe('BuyFiat entity', () => { it('does not screen when the tx would not otherwise pass', async () => { jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.FAIL } as any); - const screen = jest.fn().mockResolvedValue(true); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK); await run(createCustomBuyFiat({}), screen); @@ -156,21 +157,21 @@ describe('BuyFiat entity', () => { .spyOn(AmlHelperService, 'getAmlResult') .mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any) .mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any); - const screen = jest.fn().mockResolvedValue(true); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK); const entity = createCustomBuyFiat({}); await run(entity, screen); expect(screen).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(2); - expect(spy.mock.calls[0].at(-1)).toBe(false); // phase 1 - expect(spy.mock.calls[1].at(-1)).toBe(true); // phase 2 + expect(spy.mock.calls[0].at(-1)).toBe(ScorechainOutcome.PASS); // phase 1 + expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.HIGH_RISK); // phase 2 expect(entity.amlCheck).toBe(CheckStatus.PENDING); }); it('keeps PASS when the tx would pass and screening is clean (no phase 2)', async () => { jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.PASS } as any); - const screen = jest.fn().mockResolvedValue(false); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.PASS); const entity = createCustomBuyFiat({}); await run(entity, screen); @@ -179,6 +180,22 @@ describe('BuyFiat entity', () => { expect(AmlHelperService.getAmlResult).toHaveBeenCalledTimes(1); expect(entity.amlCheck).toBe(CheckStatus.PASS); }); + + it('screens when the tx would otherwise pass and flips to PENDING when the provider is unavailable', async () => { + const spy = jest + .spyOn(AmlHelperService, 'getAmlResult') + .mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any) + .mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.UNAVAILABLE); + const entity = createCustomBuyFiat({}); + + await run(entity, screen); + + expect(screen).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.UNAVAILABLE); + expect(entity.amlCheck).toBe(CheckStatus.PENDING); + }); }); describe('#resetAmlCheck()', () => { diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts index a0c84bd838..bef50660e4 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts @@ -28,6 +28,7 @@ import { FiatOutput } from '../../../supporting/fiat-output/fiat-output.entity'; import { Transaction } from '../../../supporting/payment/entities/transaction.entity'; import { AmlReason } from '../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../aml/enums/check-status.enum'; +import { ScorechainOutcome } from '../../aml/enums/scorechain-outcome.enum'; import { AmlHelperService } from '../../aml/services/aml-helper.service'; import { PaymentLinkPayment } from '../../payment-link/entities/payment-link-payment.entity'; import { Sell } from '../route/sell.entity'; @@ -471,9 +472,9 @@ export class BuyFiat extends IEntity { ibanCountry: Country, refUser?: User, recommender?: UserData, - screenScorechain?: () => Promise, + screenScorechain?: () => Promise, ): Promise> { - const computeAmlResult = (scorechainHighRisk: boolean) => + const computeAmlResult = (scorechainOutcome: ScorechainOutcome) => AmlHelperService.getAmlResult( this, inputAsset, @@ -492,13 +493,16 @@ export class BuyFiat extends IEntity { undefined, undefined, undefined, - scorechainHighRisk, + scorechainOutcome, ); - let amlResult = computeAmlResult(false); + let amlResult = computeAmlResult(ScorechainOutcome.PASS); + // Run the (billable) Scorechain screening only when the tx would otherwise PASS. - if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS && (await screenScorechain())) - amlResult = computeAmlResult(true); + if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS) { + const scorechainOutcome = await screenScorechain(); + if (scorechainOutcome !== ScorechainOutcome.PASS) amlResult = computeAmlResult(scorechainOutcome); + } const update: Partial = { ...amlResult, diff --git a/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts index c5cccdf047..656bc2334e 100644 --- a/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts @@ -8,6 +8,7 @@ import * as processServiceModule from 'src/shared/services/process.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { CustodyOrderService } from 'src/subdomains/core/custody/services/custody-order.service'; @@ -106,7 +107,7 @@ describe('BuyFiatPreparationService', () => { } describe('screenScorechain (Scorechain AML gate)', () => { - const call = (entity: any): Promise => (service as any).screenScorechain(entity); + const call = (entity: any): Promise => (service as any).screenScorechain(entity); let apiKeyBackup: string | undefined; @@ -136,7 +137,7 @@ describe('BuyFiatPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(true); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.HIGH_RISK); expect(scorechainScreeningService.screenDepositTransaction).toHaveBeenCalledWith(Blockchain.BITCOIN, 'txhash'); }); @@ -145,7 +146,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.MONERO }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -154,7 +155,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: undefined } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -164,7 +165,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -174,11 +175,11 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); - it('fails closed to manual review (true) when the provider throws (outage / quota reached)', async () => { + it('fails closed to manual review (UNAVAILABLE) when the provider throws (outage / quota reached)', async () => { const entity = createCustomBuyFiat({ cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); @@ -186,7 +187,19 @@ describe('BuyFiatPreparationService', () => { .spyOn(scorechainScreeningService, 'screenDepositTransaction') .mockRejectedValue(new Error('scorechain unavailable')); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); + }); + + it('fails closed to UNAVAILABLE when isHighRisk throws (misconfigured risk threshold)', async () => { + const entity = createCustomBuyFiat({ + cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, + }); + jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); + jest.spyOn(scorechainScreeningService, 'isHighRisk').mockImplementation(() => { + throw new Error('SCORECHAIN_RISK_THRESHOLD is not configured'); + }); + + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); }); it('stores a compliance report for a freshly-screened deposit tied to a customer', async () => { diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts index b8488e4d66..6b4dbaaea9 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts @@ -9,6 +9,7 @@ import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AmountType, Util } from 'src/shared/utils/util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { BlockAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { CustodyOrderStatus } from 'src/subdomains/core/custody/enums/custody'; @@ -64,15 +65,15 @@ export class BuyFiatPreparationService { // Scorechain on-chain screening for the sell/BuyFiat AML gate: screens the incoming crypto deposit // tx. Chains Scorechain does not cover yield no signal (the other AML mechanisms apply). isHighRisk // is fail-closed (invalid signature / no coverage / unsupported → high risk). - private async screenScorechain(entity: BuyFiat): Promise { + private async screenScorechain(entity: BuyFiat): Promise { // Feature gate / kill-switch: when Scorechain is disabled or unconfigured (no API key), emit no // signal so the tx is decided by the other AML mechanisms. This is the deliberate off-state and // must never route an unscreened-because-off tx to manual review. - if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return false; + if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return ScorechainOutcome.PASS; const blockchain = entity.cryptoInput?.asset.blockchain; const txHash = entity.cryptoInput?.inTxId; - if (!txHash || !toScorechainBlockchain(blockchain)) return false; + if (!txHash || !toScorechainBlockchain(blockchain)) return ScorechainOutcome.PASS; try { const screening = await this.scorechainScreeningService.screenDepositTransaction(blockchain, txHash); @@ -82,13 +83,18 @@ export class BuyFiatPreparationService { if (screening.isNewlyScreened && entity.userData) await this.scorechainDocumentService.createScreeningReport(entity.userData, screening); - return this.scorechainScreeningService.isHighRisk(screening); + return this.scorechainScreeningService.isHighRisk(screening) + ? ScorechainOutcome.HIGH_RISK + : ScorechainOutcome.PASS; } catch (e) { // Fail-closed to manual review: a provider/transport error or a reached monthly quota must not // throw out of the AML computation (which would silently stall settlement of every otherwise- - // passing tx on every cron run). Treat it as high risk → SCORECHAIN_HIGH_RISK → PENDING. - this.logger.error(`Scorechain screening failed for buy-fiat ${entity.id}, routing to manual review:`, e); - return true; + // passing tx on every cron run). + // Classify as UNAVAILABLE, never HIGH_RISK: no risk verdict could be established. A transport or quota + // error leaves no screening row and no compliance PDF at all; a missing risk threshold throws only + // after both have been persisted. In neither case may the tx be recorded as an actual Scorechain hit. + this.logger.error(`Scorechain screening unavailable for buy-fiat ${entity.id}, routing to manual review:`, e); + return ScorechainOutcome.UNAVAILABLE; } } From 65f32e8dbb3e0c924199af769435480099bcf0ec Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:32:09 +0200 Subject: [PATCH 9/9] fix(realunit): bound the tax-residence payload and stop writing user_data.tin on a rejected request (#4210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(realunit): bound the tax-residence payload and stop writing user_data.tin on a rejected request Three defects in the tax-residence contract: - user_data.tin is varchar(256), but neither CountryAndTin.tin nor countryAndTINs was length-bounded and the client allows an unbounded number of tax residences. Seven entries with an 11-digit TIN already serialize to 260 characters, so the UPDATE threw 'value too long for type character varying(256)' — AFTER forwardRegistration had already persisted the registration, turning a completed registration into a 500 that every retry reproduced. Bound the input (10 entries, 64-character TIN), widen the column to varchar(1024) to cover that bounded maximum, and reject an oversized payload up front in validateRegistrationDto so it can never fail once the registration is durable. - The idempotent branch of completeRegistration wrote user_data.tin BEFORE idempotentRegistrationResult rejects a mismatching signature, so a request answered with a 400 had already mutated the column. countryAndTINs is not part of the EIP-712 envelope, so the body cannot describe what was actually registered either. Check the signature first and sync the snapshot from the durable registration row instead of the request body. - @ValidateIf(!swissTaxResidence) disabled every validator on countryAndTINs — including @IsArray — whenever swissTaxResidence was true, so a non-array body reached the service and crashed it with a TypeError (500 instead of 400). Validate whenever the field is required or present, and keep a defensive Array.isArray guard in the service. Also replace the unguarded JSON.parse(user_data.tin) in the registration prefill: a malformed legacy value took down the only endpoint through which the user could submit a corrected declaration. It now degrades loudly (PII-free error log) to 'no known tax residences'. * fix(realunit): trim tin without crashing on a non-string value The @Transform(Util.trim) added to CountryAndTin.tin runs in class-transformer before class-validator, so a non-string tin (e.g. a number) made value.trim() throw a TypeError => HTTP 500 before @IsString could reject it as a 400 — the exact 500-instead-of-400 regression this change set exists to remove. Trim only actual strings and let any other type fall through to @IsString. * style(realunit): apply prettier to the non-string-tin regression test --- migration/1784000000000-WidenUserDataTin.js | 41 ++++ .../user/models/user-data/user-data.entity.ts | 3 +- .../realunit-registration.dto.spec.ts | 53 +++++ .../__tests__/realunit.service.spec.ts | 201 +++++++++++++++++- .../realunit/dto/realunit-registration.dto.ts | 28 ++- .../supporting/realunit/realunit.service.ts | 90 +++++++- 6 files changed, 403 insertions(+), 13 deletions(-) create mode 100644 migration/1784000000000-WidenUserDataTin.js diff --git a/migration/1784000000000-WidenUserDataTin.js b/migration/1784000000000-WidenUserDataTin.js new file mode 100644 index 0000000000..193973707a --- /dev/null +++ b/migration/1784000000000-WidenUserDataTin.js @@ -0,0 +1,41 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Widens `user_data.tin` from varchar(256) to varchar(1024). The RealUnit multi-tax-residence + * declaration is stored as a JSON array of {country, tin} in this column; 256 characters is no + * longer enough from ~7 entries on (seven 11-digit TINs already serialize past 256). Widening a + * varchar length in Postgres is a catalog-only change with no table rewrite. + * + * down() fails loud if longer values already exist — that is intentional (fail-closed: do not + * silently truncate tax-residence snapshots). + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class WidenUserDataTin1784000000000 { + name = 'WidenUserDataTin1784000000000' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // Fail fast instead of head-of-queue-blocking every "user_data" write if the ALTER is + // contended at deploy time: the timeout aborts with a clear lock_timeout error instead of + // hanging app boot. + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "user_data" ALTER COLUMN "tin" TYPE character varying(1024)`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + // Fails loud if any row already stores more than 256 characters — intentional; do not + // silently truncate a multi-tax-residence snapshot. + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "user_data" ALTER COLUMN "tin" TYPE character varying(256)`); + } +} diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 0e1971db80..ca17e47d1f 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -114,7 +114,8 @@ export class UserData extends IEntity { @Column({ type: 'timestamp', nullable: true }) birthday?: Date; - @Column({ length: 256, nullable: true }) + // RealUnit stores a JSON list of tax residences here (not a single TIN); 1024 covers the DTO-bounded max. + @Column({ length: 1024, nullable: true }) tin?: string; // --- ORGANIZATION DATA --- // diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-registration.dto.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-registration.dto.spec.ts index 8321f43b5b..f99e9a8fd2 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-registration.dto.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-registration.dto.spec.ts @@ -71,6 +71,59 @@ describe('RealUnitRegistrationDto (class-validator decorators)', () => { expect(errors.find((e) => e.property === 'countryAndTINs')).toBeUndefined(); }); + // When swissTaxResidence is true BUT countryAndTINs is present, validators must still run + // (previously @ValidateIf skipped @IsArray and a non-array body reached the service as a 500). + it('rejects a non-array countryAndTINs when swissTaxResidence is true (F3 regression)', async () => { + const errors = await validate(build({ swissTaxResidence: true, countryAndTINs: 'pwned' })); + const tinError = errors.find((e) => e.property === 'countryAndTINs'); + expect(tinError).toBeDefined(); + expect(tinError!.constraints).toHaveProperty('isArray'); + }); + + it('rejects a TIN longer than 64 characters via @MaxLength', async () => { + const errors = await validate( + build({ swissTaxResidence: true, countryAndTINs: [{ country: 'DE', tin: 'x'.repeat(65) }] }), + ); + const tinError = errors.find((e) => e.property === 'countryAndTINs'); + expect(tinError).toBeDefined(); + expect(tinError!.children?.length).toBeGreaterThan(0); + const nestedTinError = tinError!.children![0].children?.find((e) => e.property === 'tin'); + expect(nestedTinError?.constraints).toHaveProperty('maxLength'); + }); + + it('rejects more than 10 countryAndTINs entries via @ArrayMaxSize', async () => { + // Valid 2-letter codes so only ArrayMaxSize (not nested Matches) is the failure mode. + const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE', 'PL']; + const countryAndTINs = countries.map((country, i) => ({ country, tin: `TIN${i}` })); + const errors = await validate(build({ swissTaxResidence: false, countryAndTINs })); + const tinError = errors.find((e) => e.property === 'countryAndTINs'); + expect(tinError).toBeDefined(); + expect(tinError!.constraints).toHaveProperty('arrayMaxSize'); + }); + + it('trims TIN whitespace via string-safe @Transform', () => { + const dto = build({ swissTaxResidence: false, countryAndTINs: [{ country: 'DE', tin: ' DE123 ' }] }); + expect(dto.countryAndTINs![0].tin).toBe('DE123'); + }); + + // Non-string tin must not crash @Transform (Util.trim would throw TypeError => HTTP 500); + // the value must fall through so @IsString can reject it as a clean 400. + it('rejects a non-string tin as isString without throwing (400 not 500 regression)', async () => { + const errors = await validate(build({ swissTaxResidence: true, countryAndTINs: [{ country: 'DE', tin: 12345 }] })); + const tinError = errors.find((e) => e.property === 'countryAndTINs'); + expect(tinError).toBeDefined(); + expect(tinError!.children?.length).toBeGreaterThan(0); + const nestedTinError = tinError!.children![0].children?.find((e) => e.property === 'tin'); + expect(nestedTinError?.constraints).toHaveProperty('isString'); + }); + + it('accepts exactly 10 countryAndTINs entries (ArrayMaxSize boundary)', async () => { + const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE']; + const countryAndTINs = countries.map((country, i) => ({ country, tin: `TIN${i}` })); + const errors = await validate(build({ swissTaxResidence: false, countryAndTINs })); + expect(errors.find((e) => e.property === 'countryAndTINs')).toBeUndefined(); + }); + // --- kycData nested factory (@Type(() => KycPersonalData)) --- // it('instantiates kycData through the KycPersonalData type factory', () => { diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 09ed4c2808..db7ad7f339 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -2216,10 +2216,26 @@ describe('RealUnitService', () => { it('returns the idempotent result for an existing current-wallet registration', async () => { userService.getUserByAddress.mockResolvedValue({ - userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com' }, + userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', tin: null }, } as any); + // After the F2 fix the isForCurrentWallet branch reconstructs the registration via + // toRegistrationDto (reads signedPayloadData) before syncing user_data.tin from the record. jest.spyOn(service as any, 'findRegistration').mockResolvedValue({ - registration: { id: 3, signature: '0xsig', status: ReviewStatus.COMPLETED }, + registration: { + id: 3, + signature: '0xsig', + status: ReviewStatus.COMPLETED, + signedPayloadData: { + email: 'max@example.com', + name: 'Max', + walletAddress: '0xabc', + signature: '0xsig', + registrationDate: '2026-01-01', + swissTaxResidence: true, + addressCountry: 'CH', + }, + kycDataObj: { accountType: 'Personal' }, + }, isForCurrentWallet: true, }); @@ -2305,6 +2321,111 @@ describe('RealUnitService', () => { expect(status).toBe(RealUnitRegistrationStatus.FORWARDING_FAILED); }); + + it('F1: does not call forwardRegistration when countryAndTINs is too large', async () => { + // Exercise the real tax-residence guard (serialized-length fail-closed) without the full EIP-712 path. + jest.spyOn(service as any, 'validateRegistrationDto').mockImplementation(async (d: any) => { + (service as any).validateTaxResidenceCoversAddress(d); + }); + jest.spyOn(service as any, 'serializeCountryAndTins').mockReturnValue('x'.repeat(1025)); + const forwardSpy = jest.spyOn(service as any, 'forwardRegistration').mockResolvedValue(true); + + const oversizedDto: any = { + walletAddress: '0xabc', + signature: '0xsig', + email: 'max@example.com', + addressCountry: 'DE', + swissTaxResidence: false, + countryAndTINs: [{ country: 'DE', tin: 'DE123456789' }], + kycData: {}, + }; + userService.getUserByAddress.mockResolvedValue({ + userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', firstname: 'Max' }, + } as any); + + await expect(service.completeRegistration(1, oversizedDto)).rejects.toThrow(/countryAndTINs is too large/); + expect(forwardSpy).not.toHaveBeenCalled(); + }); + + it('F2: does not write user_data.tin when the idempotent path rejects a signature mismatch', async () => { + userService.getUserByAddress.mockResolvedValue({ + userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', tin: null }, + } as any); + jest.spyOn(service as any, 'findRegistration').mockResolvedValue({ + registration: { + id: 3, + signature: '0xSTORED_SIGNATURE', + status: ReviewStatus.COMPLETED, + signedPayloadData: { + email: 'max@example.com', + name: 'Max', + walletAddress: '0xabc', + signature: '0xSTORED_SIGNATURE', + registrationDate: '2026-01-01', + swissTaxResidence: true, + addressCountry: 'CH', + countryAndTINs: [{ country: 'DE', tin: 'DE-STORED' }], + }, + kycDataObj: { accountType: 'Personal' }, + }, + isForCurrentWallet: true, + }); + // Incoming signature does NOT match the stored one; body carries a different TIN set. + const mismatchDto: any = { + ...dto, + signature: '0xMISMATCHING_SIGNATURE', + countryAndTINs: [{ country: 'FR', tin: 'FR-ATTACKER' }], + }; + + await expect(service.completeRegistration(1, mismatchDto)).rejects.toThrow(BadRequestException); + + const tinWrites = (userDataService.updateUserDataInternal as jest.Mock).mock.calls + .map((c) => c[1]) + .filter((u) => u && Object.prototype.hasOwnProperty.call(u, 'tin')); + expect(tinWrites).toHaveLength(0); + expect(logService.create).not.toHaveBeenCalledWith(expect.objectContaining({ subsystem: 'UserDataTin' })); + }); + + it('F2: syncs user_data.tin from the stored registration, not the request body, on idempotent retry', async () => { + const storedTins = [{ country: 'DE', tin: 'DE-STORED' }]; + const bodyTins = [{ country: 'FR', tin: 'FR-FROM-BODY' }]; + const userData: any = { + id: 1, + kycLevel: KycLevel.LEVEL_10, + mail: 'max@example.com', + tin: null, + }; + userService.getUserByAddress.mockResolvedValue({ userData } as any); + jest.spyOn(service as any, 'findRegistration').mockResolvedValue({ + registration: { + id: 3, + signature: '0xsig', + status: ReviewStatus.COMPLETED, + signedPayloadData: { + email: 'max@example.com', + name: 'Max', + walletAddress: '0xabc', + signature: '0xsig', + registrationDate: '2026-01-01', + swissTaxResidence: true, + addressCountry: 'CH', + countryAndTINs: storedTins, + }, + kycDataObj: { accountType: 'Personal' }, + }, + isForCurrentWallet: true, + }); + logService.create.mockResolvedValue({} as any); + + const status = await service.completeRegistration(1, { ...dto, countryAndTINs: bodyTins }); + + expect(status).toBe(RealUnitRegistrationStatus.ALREADY_REGISTERED); + const tinWrites = (userDataService.updateUserDataInternal as jest.Mock).mock.calls + .map((c) => c[1]) + .filter((u) => u && Object.prototype.hasOwnProperty.call(u, 'tin')); + expect(tinWrites).toEqual([{ tin: JSON.stringify(storedTins) }]); + expect(tinWrites[0].tin).not.toContain('FR-FROM-BODY'); + }); }); describe('ponder queries (GraphQL injection protection)', () => { @@ -3205,6 +3326,55 @@ describe('RealUnitService', () => { ); }); + // --- F1/F3 defense-in-depth bounds on countryAndTINs (fail closed before forward) --- + + it('F1: rejects countryAndTINs whose serialized length exceeds MAX_SERIALIZED_TIN_LENGTH', async () => { + // Under the DTO bounds the worst-case serialization is ~890 chars; the serialized-length + // check is a fail-closed safety net for constant drift. Force it by stubbing serialize. + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [{ country: 'DE', tin: 'DE123456789' }], + ); + jest.spyOn(service as any, 'serializeCountryAndTins').mockReturnValue('x'.repeat(1025)); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/countryAndTINs is too large/); + }); + + it('F1: rejects more than 10 countryAndTINs entries at the service layer', async () => { + const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE', 'PL']; + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + countries.map((country, i) => ({ country, tin: `TIN${i}` })), + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/more than 10 entries/); + }); + + it('F1: rejects a TIN longer than 64 characters at the service layer', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [{ country: 'DE', tin: 'x'.repeat(65) }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/must not exceed 64 characters/); + }); + + it('F3: rejects a non-array countryAndTINs with BadRequestException (not TypeError)', () => { + for (const countryAndTINs of ['pwned', { a: 1 }, 42] as any[]) { + expect(() => + (service as any).validateTaxResidenceCoversAddress({ + swissTaxResidence: true, + addressCountry: 'CH', + countryAndTINs, + }), + ).toThrow(BadRequestException); + expect(() => + (service as any).validateTaxResidenceCoversAddress({ + swissTaxResidence: true, + addressCountry: 'CH', + countryAndTINs, + }), + ).toThrow(/countryAndTINs must be an array/); + } + }); + it('resolveSignedRegistrationMessage normalizes a signature that lacks the 0x prefix', async () => { const fields = humanFields(); const signature = await wallet._signTypedData(domain, types, fields); @@ -3612,6 +3782,33 @@ describe('RealUnitService', () => { country: { symbol: 'CH', id: 7 }, }); }); + + // F4: malformed / contract-violating user_data.tin must degrade the prefill loudly, never throw. + it('F4: degrades prefill to empty countryAndTINs when tin is not valid JSON', () => { + const userData: any = { id: 99, firstname: 'Erika', tin: 'not json' }; + const dto = (service as any).toUserDataDtoFromUserData(userData); + expect(dto).toBeDefined(); + expect(dto.countryAndTINs).toBeUndefined(); + expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('not valid JSON')); + }); + + it('F4: degrades prefill to empty countryAndTINs when tin is a non-array JSON value', () => { + const userData: any = { id: 99, firstname: 'Erika', tin: '{"a":1}' }; + const dto = (service as any).toUserDataDtoFromUserData(userData); + expect(dto).toBeDefined(); + expect(dto.countryAndTINs).toBeUndefined(); + expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('not an array')); + }); + + it('F4: drops a contract-violating CH entry from the prefill without throwing', () => { + const userData: any = { id: 99, firstname: 'Erika', tin: '[{"country":"CH","tin":"x"}]' }; + const dto = (service as any).toUserDataDtoFromUserData(userData); + expect(dto).toBeDefined(); + expect(dto.countryAndTINs).toBeUndefined(); + expect((service as any).logger.error).toHaveBeenCalledWith( + expect.stringContaining('violating the registration contract'), + ); + }); }); describe('isPersonalDataMatching (organization account branch)', () => { diff --git a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts index 4076b596d7..29417a7ec0 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { + ArrayMaxSize, IsArray, IsBoolean, IsEmail, @@ -18,6 +19,14 @@ import { Util } from 'src/shared/utils/util'; import { KycPersonalData } from 'src/subdomains/generic/kyc/dto/input/kyc-data.dto'; import { DfxPhoneTransform, IsDfxPhone } from 'src/subdomains/generic/user/models/user-data/is-dfx-phone.validator'; +// Bounds for the `user_data.tin` snapshot, which stores countryAndTINs as a JSON array of +// {country, tin}. The column is varchar(1024); the bounded worst case (10 entries x 64-char TIN) +// serializes to ~890 characters and therefore always fits. Keeping the three limits together makes +// the DB invariant explicit instead of implicit in the JSON envelope arithmetic. +export const MAX_TAX_RESIDENCES = 10; +export const MAX_TIN_LENGTH = 64; +export const MAX_SERIALIZED_TIN_LENGTH = 1024; + export enum RealUnitUserType { HUMAN = 'HUMAN', CORPORATION = 'CORPORATION', @@ -84,6 +93,11 @@ export class CountryAndTin { @ApiProperty({ description: 'Tax identification number' }) @IsNotEmpty() @IsString() + @MaxLength(MAX_TIN_LENGTH) + // NOT Util.trim: class-transformer runs this before class-validator, so a non-string tin (e.g. a + // number) would make `value.trim()` throw a TypeError => HTTP 500 before @IsString can reject it as + // a 400. Trim only actual strings and let any other type fall through to @IsString. + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) tin: string; } @@ -191,15 +205,19 @@ export class AktionariatRegistrationDto { description: 'Tax residences with TINs for non-CH countries only (never CH — use swissTaxResidence). ' + 'Required when swissTaxResidence is false. Multi-residence TINs may also be sent when ' + - 'swissTaxResidence is true; nested shape is enforced by the service. addressCountry must be ' + + 'swissTaxResidence is true (max 10 entries, each TIN max 64 chars). addressCountry must be ' + 'covered: CH via swissTaxResidence, any other country via an entry here.', }) - // Presence required only for non-Swiss. Nested shape for multi-residence with - // swissTaxResidence=true is enforced in RealUnitService.validateTaxResidenceCoversAddress - // (class-validator ValidateIf cannot both require-when-false and nested-check-when-present cleanly). - @ValidateIf((o: AktionariatRegistrationDto) => !o.swissTaxResidence) + // Validate whenever the field is REQUIRED (no Swiss tax residence declared) OR whenever it is + // present at all. The previous condition (`!o.swissTaxResidence` alone) skipped EVERY validator — + // including @IsArray — as soon as swissTaxResidence was true, so a non-array body slipped through + // the pipe and crashed the service with a TypeError (HTTP 500 instead of 400). + @ValidateIf((o: AktionariatRegistrationDto) => !o.swissTaxResidence || o.countryAndTINs !== undefined) @IsNotEmpty({ message: 'countryAndTINs is required when swissTaxResidence is false' }) @IsArray() + @ArrayMaxSize(MAX_TAX_RESIDENCES, { + message: `countryAndTINs must not contain more than ${MAX_TAX_RESIDENCES} entries`, + }) @ValidateNested({ each: true }) @Type(() => CountryAndTin) countryAndTINs?: CountryAndTin[]; diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index ac293c8719..a989c1b195 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -90,6 +90,9 @@ import { import { RealUnitDtoMapper } from './dto/realunit-dto.mapper'; import { AktionariatRegistrationDto, + MAX_SERIALIZED_TIN_LENGTH, + MAX_TAX_RESIDENCES, + MAX_TIN_LENGTH, RealUnitEmailRegistrationDto, RealUnitEmailRegistrationStatus, RealUnitLanguage, @@ -745,10 +748,19 @@ export class RealUnitService { dto.walletAddress, ); if (isForCurrentWallet) { - // Registration row is already durable — still sync user_data.tin (e.g. retry after a - // previous attempt that forwarded successfully but failed the tin audit write). - await this.persistUserDataTinAfterRegistration(userData, dto); - return this.idempotentRegistrationResult(userData, existingRegistration!, dto.signature); + // Signature check FIRST: idempotentRegistrationResult rejects a mismatching signature with a 400, + // and a request that is about to be rejected must not have mutated the database on its way out. + const status = await this.idempotentRegistrationResult(userData, existingRegistration!, dto.signature); + + // The snapshot follows the event of record, never the request body. countryAndTINs is NOT part of + // the EIP-712 envelope, so a replay could otherwise rewrite user_data.tin to something that was + // never registered with Aktionariat. Syncing from the durable registration row also self-heals a + // retry whose forward succeeded but whose tin audit write failed. + const registrationData = this.toRegistrationDto(existingRegistration!); + if (!registrationData) throw new BadRequestException('Invalid registration data'); + await this.persistUserDataTinAfterRegistration(userData, registrationData); + + return status; } // validate personal data @@ -899,6 +911,17 @@ export class RealUnitService { private validateTaxResidenceCoversAddress(dto: RealUnitRegistrationDto): void { const entries = dto.countryAndTINs ?? []; + // Defense in depth: the DTO enforces this too, but the service must never index into a non-array. + // A non-array body used to reach here (the DTO's @ValidateIf skipped @IsArray whenever + // swissTaxResidence was true) and crashed with a TypeError => HTTP 500 instead of a 400. + if (!Array.isArray(entries)) { + throw new BadRequestException('countryAndTINs must be an array'); + } + + if (entries.length > MAX_TAX_RESIDENCES) { + throw new BadRequestException(`countryAndTINs must not contain more than ${MAX_TAX_RESIDENCES} entries`); + } + if (entries.some((e) => e.country === 'CH')) { throw new BadRequestException( 'countryAndTINs must not include CH; set swissTaxResidence for Swiss tax residence', @@ -914,6 +937,9 @@ export class RealUnitService { if (typeof entry.tin !== 'string' || !entry.tin.trim()) { throw new BadRequestException('countryAndTINs.tin must be a non-empty string'); } + if (entry.tin.length > MAX_TIN_LENGTH) { + throw new BadRequestException(`countryAndTINs.tin must not exceed ${MAX_TIN_LENGTH} characters`); + } } const tinCountries = entries.map((e) => e.country); @@ -931,6 +957,14 @@ export class RealUnitService { if (!taxCountries.has(dto.addressCountry)) { throw new BadRequestException(`Tax residence must include the residence country (${dto.addressCountry})`); } + + // Fail closed BEFORE anything is forwarded. user_data.tin is a bounded column and is written only + // AFTER forwardRegistration has persisted the registration — an oversized payload must therefore be + // rejected here with a 400, never blow up as a 500 on a registration that is already durable. + const serialized = this.serializeCountryAndTins(entries); + if (serialized && serialized.length > MAX_SERIALIZED_TIN_LENGTH) { + throw new BadRequestException('countryAndTINs is too large'); + } } // Canonical shape for user_data.tin: JSON array of {country, tin}, or null when the @@ -1254,6 +1288,52 @@ export class RealUnitService { return userDataDto as RealUnitUserDataDto; } + // user_data.tin is written exclusively by persistUserDataTinAfterRegistration, as a JSON array of + // {country, tin} with no CH entry. A malformed or contract-violating legacy value must not take this + // prefill down: GET registration-info is the only channel through which the user can submit a + // CORRECTED declaration, so throwing here would lock the account out of the very fix it needs. The + // bad value is therefore dropped LOUDLY (error log, no PII) and the prefill degrades to "no known tax + // residences" — it never hands the client back a payload that validateTaxResidenceCoversAddress would + // reject. + private parseStoredTin(userData: UserData): { country: string; tin: string }[] { + if (!userData.tin) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(userData.tin); + } catch { + this.logger.error( + `Malformed user_data.tin for userData ${userData.id}: not valid JSON — prefill degraded to empty`, + ); + return []; + } + + if (!Array.isArray(parsed)) { + this.logger.error( + `Malformed user_data.tin for userData ${userData.id}: not an array — prefill degraded to empty`, + ); + return []; + } + + const entries = parsed.filter( + (e): e is { country: string; tin: string } => + !!e && + typeof e.country === 'string' && + /^[A-Z]{2}$/.test(e.country) && + e.country !== 'CH' && + typeof e.tin === 'string' && + !!e.tin.trim(), + ); + + if (entries.length !== parsed.length) { + this.logger.error( + `user_data.tin for userData ${userData.id} contains ${parsed.length - entries.length} entr(ies) violating the registration contract — dropped from the prefill`, + ); + } + + return entries; + } + // Pre-fill source for first-time RealUnit registrations: maps the user's existing DFX KYC data into // the Aktionariat-shaped DTO. The corresponding `completeRegistration` validation // (`isPersonalDataMatching`) compares the submitted KycPersonalData/address against the same @@ -1265,7 +1345,7 @@ export class RealUnitService { const lang = Object.values(RealUnitLanguage).find((l) => l === userData.language?.symbol?.toUpperCase()); const addressStreet = [userData.street, userData.houseNumber].filter((s) => s).join(' '); - const tinEntries: { country: string; tin: string }[] = userData.tin ? JSON.parse(userData.tin) : []; + const tinEntries = this.parseStoredTin(userData); return { email: userData.mail ?? '',