From 662bb9d5e43367b73f9ce4ea80f9ac1125902d1d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:23:00 +0200 Subject: [PATCH 01/19] fix(payout): make the payout designation an atomic conditional state transition (#4236) * fix(payout): make the payout designation an atomic conditional state transition The designation introduced for the designate-before-broadcast protection was a plain full-entity save with no status precondition. The payout cron lock times out after 1800s while a run may still be executing, so two overlapping runs could both select the same PreparationConfirmed orders: a stale run could re-broadcast an order already broadcast by a newer run and overwrite its persisted PayoutPending/payoutTxId state. Designation is now a conditional UPDATE (WHERE status = PreparationConfirmed); a caller that loses the race skips the order entirely. On the bitcoin-family batch path, designation runs before aggregation and only the winning orders are dispatched, failure-tracked and saved. Closes #4232 * fix(payout): handle claim errors fail-safe and make retry rollback and escalation conditional Review follow-ups: the conditional designation criteria move into a shared claimForBroadcast helper that also absorbs UPDATE errors (an unclaimed order stays re-selectable - escalating or rolling back an order this run never owned would reintroduce the stale-save class). The OOG retry release and the processFailedOrders escalation become conditional transitions as well, since both could overwrite a concurrently persisted PayoutPending/payoutTxId via full-entity saves. Escalation mails now cover only actually escalated orders. * style: format payout strategies and service spec * test: fix loser-exclusion assertion (untouched order has no lastError) * chore: remove stray scratch file * fix(payout): make the failed-order escalation resilient and overlap-aware Review follow-ups: processFailedOrders now skips orders designated within the last minute (an overlapping run is broadcasting them right now - escalating would alert on an order that resolves itself moments later), isolates per-order escalation failures instead of stranding already-escalated orders unmailed, and keeps the alert retryable: if the mail fails, the escalated orders are conditionally reverted to PayoutDesignated so the next cycle re-attempts escalation and mail. The mail lists only orders this run actually escalated. * test: empty-tx-id regression asserts no entity save under conditional designation * fix(payout): drop the dead pendingInvestigation helper and pin escalation call counts Review follow-ups: the conditional escalation removed the helper's only production caller; the success-path tests now pin the exact number of conditional updates (a revert after a successful mail would add calls), and the escalation comment states the alert-delivery residuals accurately (retryable up to the notification persistence; transport-level delivery is owned by the notification subsystem). * test: pin the escalation update count in the intended success test --- .../__tests__/payout-order.entity.spec.ts | 14 -- .../payout/entities/payout-order.entity.ts | 6 - .../services/__tests__/payout.service.spec.ts | 147 +++++++++++++- .../payout/services/payout.service.ts | 61 +++++- .../payout-bitcoin-based.strategy.spec.ts | 180 ++++++++++++++++-- ...esignate-before-broadcast.strategy.spec.ts | 151 ++++++++------- .../payout/__tests__/payout-evm-retry.spec.ts | 61 +++++- .../payout-nonevm-strategies.spec.ts | 12 ++ .../__tests__/payout-zano.strategy.spec.ts | 1 + .../__tests__/payout.strategy.base.spec.ts | 34 +++- .../strategies/payout/impl/arkade.strategy.ts | 2 +- .../impl/base/bitcoin-based.strategy.ts | 23 ++- .../payout/impl/base/cardano.strategy.ts | 2 +- .../payout/impl/base/evm.strategy.ts | 5 +- .../payout/impl/base/icp.strategy.ts | 2 +- .../payout/impl/base/payout.strategy.ts | 66 ++++++- .../payout/impl/base/solana.strategy.ts | 2 +- .../payout/impl/base/tron.strategy.ts | 2 +- .../payout/impl/lightning.strategy.ts | 2 +- .../strategies/payout/impl/spark.strategy.ts | 2 +- 20 files changed, 622 insertions(+), 153 deletions(-) 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 c72523e9fc..2f64a93969 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 @@ -73,20 +73,6 @@ describe('PayoutOrder', () => { }); }); - describe('#pendingInvestigation(...)', () => { - it('sets status to PayoutOrderStatus.PAYOUT_UNCERTAIN in order to filter out order from normal flow', () => { - const entity = createCustomPayoutOrder({ - status: undefined, - }); - - expect(entity.status).toBeUndefined(); - - entity.pendingInvestigation(); - - expect(entity.status).toBe(PayoutOrderStatus.PAYOUT_UNCERTAIN); - }); - }); - describe('#pendingPayout(...)', () => { it('sets transferTxId', () => { const entity = createCustomPayoutOrder({ diff --git a/src/subdomains/supporting/payout/entities/payout-order.entity.ts b/src/subdomains/supporting/payout/entities/payout-order.entity.ts index fd4204cbee..bd5a3d2f1f 100644 --- a/src/subdomains/supporting/payout/entities/payout-order.entity.ts +++ b/src/subdomains/supporting/payout/entities/payout-order.entity.ts @@ -116,12 +116,6 @@ export class PayoutOrder extends IEntity { return this; } - pendingInvestigation(): this { - this.status = PayoutOrderStatus.PAYOUT_UNCERTAIN; - - return this; - } - pendingPayout(payoutTxId: string) { if (!payoutTxId) throw new Error('No payoutTxId provided to PayoutOrder #pendingPayout(...)'); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts index 3231b0cca9..2cfcb0589f 100644 --- a/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts +++ b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts @@ -2,8 +2,9 @@ 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 { Util } from 'src/shared/utils/util'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; -import { In, MoreThan } from 'typeorm'; +import { In, LessThan, 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'; @@ -137,6 +138,7 @@ describe('PayoutService', () => { // 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, 'update').mockResolvedValue({ affected: 1 } as any); jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); doPayoutSpy = jest.fn(); @@ -175,16 +177,20 @@ describe('PayoutService', () => { expect(doPayoutSpy).not.toHaveBeenCalledWith(expect.arrayContaining([crashedOrder])); }); - it('processFailedOrders marks a stuck order PAYOUT_UNCERTAIN, persists it and alerts via mail (no doPayout)', async () => { + it('processFailedOrders conditionally escalates a stuck order and alerts via mail (no stale save)', async () => { const crashedOrder = createCustomPayoutOrder({ id: 12, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + const updateSpy = jest.spyOn(payoutOrderRepo, 'update'); 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(updateSpy).toHaveBeenCalledWith( + { id: crashedOrder.id, status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + { status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + ); + expect(saveSpy).not.toHaveBeenCalled(); expect(sendMailSpy).toHaveBeenCalledTimes(1); expect(doPayoutSpy).not.toHaveBeenCalled(); }); @@ -264,17 +270,146 @@ describe('PayoutService', () => { }); describe('#processFailedOrders(...)', () => { - it('does nothing when there is no PAYOUT_DESIGNATED order (no mail, no save)', async () => { + it('does nothing when there is no PAYOUT_DESIGNATED order (no update, mail or save)', async () => { + const cutoff = new Date('2026-07-16T12:00:00.000Z'); + const minutesBeforeSpy = jest.spyOn(Util, 'minutesBefore').mockReturnValue(cutoff); const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([]); + const updateSpy = jest.spyOn(payoutOrderRepo, 'update'); const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); const sendMailSpy = jest.spyOn(notificationService, 'sendMail'); await service['processFailedOrders'](); - expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + expect(minutesBeforeSpy).toHaveBeenCalledWith(1); + expect(findBySpy).toHaveBeenCalledWith({ + status: PayoutOrderStatus.PAYOUT_DESIGNATED, + updated: LessThan(cutoff), + }); + expect(updateSpy).not.toHaveBeenCalled(); expect(sendMailSpy).not.toHaveBeenCalled(); expect(saveSpy).not.toHaveBeenCalled(); }); + + it('mails and logs only orders whose conditional escalation succeeds', async () => { + const escalatedOrder = createCustomPayoutOrder({ id: 30, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + const movedOrder = createCustomPayoutOrder({ id: 31, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([escalatedOrder, movedOrder]); + const updateSpy = jest + .spyOn(payoutOrderRepo, 'update') + .mockResolvedValueOnce({ affected: 1 } as any) + .mockResolvedValueOnce({ affected: 0 } as any); + const logFailedOrdersSpy = jest + .spyOn(service['logs'], 'logFailedOrders') + .mockReturnValue('Escalated payout order'); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + + await service['processFailedOrders'](); + + expect(logFailedOrdersSpy).toHaveBeenCalledWith([escalatedOrder]); + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: expect.stringContaining(`|${escalatedOrder.id}&${escalatedOrder.context}|`), + }), + ); + expect(sendMailSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: expect.stringContaining(`|${movedOrder.id}&${movedOrder.context}|`), + }), + ); + // exactly the two escalation attempts - a revert after a successful mail would add calls + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it('does not mail when every conditional escalation loses to a concurrent state change', async () => { + const movedOrder = createCustomPayoutOrder({ id: 32, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([movedOrder]); + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValueOnce({ affected: 0 } as any); + const logFailedOrdersSpy = jest.spyOn(service['logs'], 'logFailedOrders'); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail'); + const infoSpy = jest.spyOn(service['logger'], 'info'); + + await service['processFailedOrders'](); + + expect(infoSpy).toHaveBeenCalledWith( + `Skipping failed payout order ${movedOrder.id}: state changed concurrently`, + ); + expect(logFailedOrdersSpy).not.toHaveBeenCalled(); + expect(sendMailSpy).not.toHaveBeenCalled(); + }); + + it('continues escalating and mails successful orders when one conditional update throws', async () => { + const failedOrder = createCustomPayoutOrder({ id: 33, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + const escalatedOrder = createCustomPayoutOrder({ id: 34, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([failedOrder, escalatedOrder]); + const updateError = new Error('database unavailable'); + const updateSpy = jest + .spyOn(payoutOrderRepo, 'update') + .mockRejectedValueOnce(updateError) + .mockResolvedValueOnce({ affected: 1 } as any); + const logFailedOrdersSpy = jest + .spyOn(service['logs'], 'logFailedOrders') + .mockReturnValue('Escalated payout order'); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + await expect(service['processFailedOrders']()).resolves.toBeUndefined(); + + expect(updateSpy).toHaveBeenNthCalledWith( + 1, + { id: failedOrder.id, status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + { status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + ); + expect(updateSpy).toHaveBeenNthCalledWith( + 2, + { id: escalatedOrder.id, status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + { status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + ); + expect(warnSpy).toHaveBeenCalledWith( + `Failed to escalate payout order ${failedOrder.id}; retrying next cycle:`, + updateError, + ); + // exactly the two escalation attempts - a revert after a successful mail would add calls + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(logFailedOrdersSpy).toHaveBeenCalledWith([escalatedOrder]); + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: expect.stringContaining(`|${escalatedOrder.id}&${escalatedOrder.context}|`), + }), + ); + }); + + it('reverts every escalation conditionally and logs the error when sending the alert fails', async () => { + const firstOrder = createCustomPayoutOrder({ id: 35, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + const secondOrder = createCustomPayoutOrder({ id: 36, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([firstOrder, secondOrder]); + const updateSpy = jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); + jest.spyOn(service['logs'], 'logFailedOrders').mockReturnValue('Escalated payout orders'); + const mailError = new Error('mail unavailable'); + jest.spyOn(notificationService, 'sendMail').mockRejectedValue(mailError); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['processFailedOrders']()).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalledWith( + `Failed to send payout failure alert for orders ${firstOrder.id},${secondOrder.id}:`, + mailError, + ); + expect(updateSpy).toHaveBeenCalledTimes(4); + expect(updateSpy).toHaveBeenNthCalledWith( + 3, + { id: firstOrder.id, status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + expect(updateSpy).toHaveBeenNthCalledWith( + 4, + { id: secondOrder.id, status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + }); }); }); diff --git a/src/subdomains/supporting/payout/services/payout.service.ts b/src/subdomains/supporting/payout/services/payout.service.ts index 389bae6207..a0af6f44d3 100644 --- a/src/subdomains/supporting/payout/services/payout.service.ts +++ b/src/subdomains/supporting/payout/services/payout.service.ts @@ -7,7 +7,7 @@ import { DfxCron } from 'src/shared/utils/cron'; 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 { FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; +import { FindOptionsRelations, In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; import { MailRequest } from '../../notification/interfaces'; import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../entities/payout-order.entity'; import { PayoutOrderFactory } from '../factories/payout-order.factory'; @@ -235,18 +235,61 @@ export class PayoutService { } private async processFailedOrders(): Promise { - const orders = await this.payoutOrderRepo.findBy({ status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + // A freshly designated order is being broadcast now and its run will resolve it; a crash leftover will still + // be DESIGNATED one cycle later. Waiting two cron periods avoids false positives and delays escalation once. + const orders = await this.payoutOrderRepo.findBy({ + status: PayoutOrderStatus.PAYOUT_DESIGNATED, + updated: LessThan(Util.minutesBefore(1)), + }); + const escalatedOrders: PayoutOrder[] = []; - if (orders.length === 0) return; + for (const order of orders) { + try { + const result = await this.payoutOrderRepo.update( + { id: order.id, status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + { status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + ); + if (!result.affected) { + this.logger.info(`Skipping failed payout order ${order.id}: state changed concurrently`); + continue; + } - const logMessage = this.logs.logFailedOrders(orders); - const mailRequest = this.createMailRequest(logMessage, orders); + escalatedOrders.push(order); + } catch (e) { + this.logger.warn(`Failed to escalate payout order ${order.id}; retrying next cycle:`, e); + continue; + } + } - await this.notificationService.sendMail(mailRequest); + if (escalatedOrders.length === 0) return; - for (const order of orders) { - order.pendingInvestigation(); - await this.payoutOrderRepo.save(order); + const logMessage = this.logs.logFailedOrders(escalatedOrders); + const mailRequest = this.createMailRequest(logMessage, escalatedOrders); + + // Escalation-then-mail keeps the alert truthful by listing only orders this run actually escalated. + // Reverting after a mail failure keeps the alert retryable for errors up to the notification + // persistence; transport-level delivery is owned by the notification subsystem. Residuals (a crash + // between escalation and mail, a delivery failure after persistence): the orders stay findable by + // querying PAYOUT_UNCERTAIN. + try { + await this.notificationService.sendMail(mailRequest); + } catch (e) { + this.logger.error(`Failed to send payout failure alert for orders ${escalatedOrders.map((o) => o.id)}:`, e); + + for (const order of escalatedOrders) { + try { + const result = await this.payoutOrderRepo.update( + { id: order.id, status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + if (!result.affected) + this.logger.warn( + `Failed to revert payout order ${order.id} after alert failure: state changed concurrently`, + ); + } catch (revertError) { + this.logger.warn(`Failed to revert payout order ${order.id} after alert failure:`, revertError); + } + } } } 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 360bb91546..875c27c684 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 @@ -29,6 +29,7 @@ describe('PayoutBitcoinBasedStrategy', () => { let payoutOrderRepo: PayoutOrderRepository; let bitcoinService: PayoutBitcoinBasedService; + let repoUpdateSpy: jest.SpyInstance; let repoSaveSpy: jest.SpyInstance; let sendErrorMailSpy: jest.SpyInstance; @@ -37,6 +38,7 @@ describe('PayoutBitcoinBasedStrategy', () => { payoutOrderRepo = mock(); bitcoinService = mock(); + repoUpdateSpy = jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save'); sendErrorMailSpy = jest.spyOn(notificationService, 'sendMail'); @@ -44,6 +46,7 @@ describe('PayoutBitcoinBasedStrategy', () => { }); afterEach(() => { + repoUpdateSpy.mockClear(); repoSaveSpy.mockClear(); sendErrorMailSpy.mockClear(); }); @@ -189,28 +192,30 @@ describe('PayoutBitcoinBasedStrategy', () => { }); describe('#designatePayout(...)', () => { - it('sets every order a PAYOUT_DESIGNATED status', async () => { + it('returns and designates every order whose conditional update succeeds', async () => { const orders = [ createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }), createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }), createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }), ]; - await strategy.designatePayoutWrapper(orders); + const designated = await strategy.designatePayoutWrapper(orders); - expect(orders.every((order) => order.status === PayoutOrderStatus.PAYOUT_DESIGNATED)); + expect(designated).toEqual(orders); + expect(orders.every((order) => order.status === PayoutOrderStatus.PAYOUT_DESIGNATED)).toBe(true); + expect(repoUpdateSpy).toHaveBeenCalledTimes(3); }); - it('saves updated order to repo', async () => { - const orders = [ - createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }), - createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }), - createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }), - ]; + it('uses the exact conditional status transition without saving a stale entity', async () => { + const order = createCustomPayoutOrder({ id: 42, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); - await strategy.designatePayoutWrapper(orders); + await strategy.designatePayoutWrapper([order]); - expect(repoSaveSpy).toBeCalledTimes(3); + expect(repoUpdateSpy).toHaveBeenCalledWith( + { id: order.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + expect(repoSaveSpy).not.toHaveBeenCalled(); }); }); @@ -254,6 +259,126 @@ describe('PayoutBitcoinBasedStrategy', () => { expect(orders[0].retryCount).toBe(0); }); + it('dispatches only winners when one order loses the designation race', async () => { + const asset = createCustomAsset({ name: 'BTC' }); + const orders = [ + createCustomPayoutOrder({ + id: 50, + asset, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + destinationAddress: 'WINNER_1', + amount: 1, + }), + createCustomPayoutOrder({ + id: 51, + asset, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + destinationAddress: 'LOSER', + amount: 2, + }), + createCustomPayoutOrder({ + id: 52, + asset, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + destinationAddress: 'WINNER_2', + amount: 3, + }), + ]; + repoUpdateSpy + .mockResolvedValueOnce({ affected: 1 } as any) + .mockResolvedValueOnce({ affected: 0 } as any) + .mockResolvedValueOnce({ affected: 1 } as any); + const dispatchSpy = jest.fn().mockResolvedValue('CHAIN_TX_ID'); + strategy.dispatchPayoutImpl = dispatchSpy; + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(dispatchSpy).toHaveBeenCalledWith( + PayoutOrderContext.BUY_CRYPTO, + [ + { addressTo: 'WINNER_1', amount: 1 }, + { addressTo: 'WINNER_2', amount: 3 }, + ], + asset, + ); + expect(dispatchSpy).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([{ addressTo: 'LOSER', amount: 2 }]), + expect.anything(), + ); + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(orders[1].status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(orders[2].status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); + expect(repoSaveSpy).not.toHaveBeenCalledWith(orders[1]); + }); + + it('skips a failed designation update and still dispatches the other winners', async () => { + const asset = createCustomAsset({ name: 'BTC' }); + const orders = [ + createCustomPayoutOrder({ + id: 53, + asset, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + destinationAddress: 'WINNER_1', + amount: 1, + }), + createCustomPayoutOrder({ + id: 54, + asset, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + destinationAddress: 'FAILED_CLAIM', + amount: 2, + }), + createCustomPayoutOrder({ + id: 55, + asset, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + destinationAddress: 'WINNER_2', + amount: 3, + }), + ]; + repoUpdateSpy + .mockResolvedValueOnce({ affected: 1 } as any) + .mockRejectedValueOnce(new Error('database unavailable')) + .mockResolvedValueOnce({ affected: 1 } as any); + const dispatchSpy = jest.fn().mockResolvedValue('CHAIN_TX_ID'); + strategy.dispatchPayoutImpl = dispatchSpy; + + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).resolves.toBeUndefined(); + + expect(dispatchSpy).toHaveBeenCalledWith( + PayoutOrderContext.BUY_CRYPTO, + [ + { addressTo: 'WINNER_1', amount: 1 }, + { addressTo: 'WINNER_2', amount: 3 }, + ], + asset, + ); + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(orders[1].status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(orders[2].status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); + expect(repoSaveSpy).not.toHaveBeenCalledWith(orders[1]); + }); + + it('does not dispatch or save when every order loses the designation race', async () => { + const orders = [ + createCustomPayoutOrder({ id: 60, status: PayoutOrderStatus.PREPARATION_CONFIRMED }), + createCustomPayoutOrder({ id: 61, status: PayoutOrderStatus.PREPARATION_CONFIRMED }), + ]; + repoUpdateSpy.mockResolvedValue({ affected: 0 } as any); + const dispatchSpy = jest.fn().mockResolvedValue('CHAIN_TX_ID'); + strategy.dispatchPayoutImpl = dispatchSpy; + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(repoUpdateSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + expect(orders.every((order) => order.status === PayoutOrderStatus.PREPARATION_CONFIRMED)).toBe(true); + }); + // 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. @@ -277,6 +402,37 @@ describe('PayoutBitcoinBasedStrategy', () => { expect(orders[0].status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); }); + it('tracks and rolls back only claim winners when dispatch throws a plain Error', async () => { + const winners = [ + createCustomPayoutOrder({ id: 70, status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }), + createCustomPayoutOrder({ id: 72, status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }), + ]; + const loser = createCustomPayoutOrder({ + id: 71, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + const orders = [winners[0], loser, winners[1]]; + repoUpdateSpy + .mockResolvedValueOnce({ affected: 1 } as any) + .mockResolvedValueOnce({ affected: 0 } as any) + .mockResolvedValueOnce({ affected: 1 } as any); + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('pre-broadcast failure')); + const trackSpy = jest.spyOn(strategy as any, 'trackPayoutFailure'); + const rollbackSpy = jest.spyOn(strategy as any, 'rollbackPayoutDesignation'); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(trackSpy).toHaveBeenCalledTimes(1); + expect(trackSpy).toHaveBeenCalledWith(winners, expect.any(Error)); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(rollbackSpy).toHaveBeenCalledWith(winners); + expect(loser.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(loser.retryCount).toBe(0); + expect(loser.lastError).toBeUndefined(); + expect(repoSaveSpy).not.toHaveBeenCalledWith(loser); + }); + // 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". @@ -366,7 +522,7 @@ describe('PayoutBitcoinBasedStrategy', () => { payoutTxId: null, }); strategy.dispatchPayoutImpl = () => Promise.resolve('CHAIN_TX_ID'); - // designatePayout persists PAYOUT_DESIGNATED fine; only the final PAYOUT_PENDING save fails. + // Designation uses the conditional update; the final PAYOUT_PENDING save fails. repoSaveSpy.mockImplementation(async (o: PayoutOrder) => { if (o.status === PayoutOrderStatus.PAYOUT_PENDING) throw new Error('db down'); return o; 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 index efd54d94fb..dfde6cf2b7 100644 --- 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 @@ -25,12 +25,13 @@ 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. +// broadcast sink (dispatchPayout/sendTransaction/sendPayment) and repository operations are +// individually spy-able, so the shared variant suite can drive and assert the atomic +// designate-before-broadcast behavior identically across all eight strategies. interface DesignateBeforeBroadcastFixture { doPayout: (orders: PayoutOrder[]) => Promise; dispatchSpy: jest.SpyInstance; + repoUpdateSpy: jest.SpyInstance; repoSaveSpy: jest.SpyInstance; } @@ -42,13 +43,6 @@ interface DesignateBeforeBroadcastOptions { // + 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 @@ -59,36 +53,32 @@ function runDesignateBeforeBroadcastSuite( setup: () => DesignateBeforeBroadcastFixture, options: DesignateBeforeBroadcastOptions = {}, ): void { - const { broadcastError = new Error('broadcast failed'), designateSaveFailureRollsBack = false } = options; + const { broadcastError = new Error('broadcast failed') } = 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(); + it('claims the order with a conditional update before broadcasting, then reaches PAYOUT_PENDING', async () => { + const { doPayout, dispatchSpy, repoUpdateSpy, 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(repoUpdateSpy).toHaveBeenCalledWith( + { id: order.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, + { status: 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 + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // pending only; designation uses UPDATE }); it('leaves the order PAYOUT_DESIGNATED (no txId, no rollback) when the broadcast throws', async () => { - const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const { doPayout, dispatchSpy, repoUpdateSpy, repoSaveSpy } = setup(); const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); dispatchSpy.mockRejectedValue(broadcastError); @@ -99,11 +89,12 @@ function runDesignateBeforeBroadcastSuite( 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 + expect(repoUpdateSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).not.toHaveBeenCalled(); }); it('does NOT re-designate when payoutTxId is already set (speedup/expired-retry path)', async () => { - const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const { doPayout, dispatchSpy, repoUpdateSpy, repoSaveSpy } = setup(); const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'OLD_TX' }); const designateSpy = jest.spyOn(order, 'designatePayout'); dispatchSpy.mockResolvedValue('NEW_TX'); @@ -111,36 +102,60 @@ function runDesignateBeforeBroadcastSuite( await doPayout([order]); expect(designateSpy).not.toHaveBeenCalled(); + expect(repoUpdateSpy).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 + expect(repoSaveSpy).toHaveBeenCalledTimes(1); }); - 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')); + it('skips broadcast and persistence on a second payout run that loses the designation race', async () => { + const { doPayout, dispatchSpy, repoUpdateSpy, repoSaveSpy } = setup(); + const firstRunOrder = createCustomPayoutOrder({ + id: 88, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + const secondRunStaleOrder = createCustomPayoutOrder({ + id: firstRunOrder.id, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + dispatchSpy.mockResolvedValue('TX_FIRST_RUN'); + + await doPayout([firstRunOrder]); + + dispatchSpy.mockClear(); + repoUpdateSpy.mockClear(); + repoSaveSpy.mockClear(); + repoUpdateSpy.mockResolvedValueOnce({ affected: 0 } as any); + + await expect(doPayout([secondRunStaleOrder])).resolves.toBeUndefined(); + + expect(repoUpdateSpy).toHaveBeenCalledWith( + { id: secondRunStaleOrder.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + expect(secondRunStaleOrder.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(secondRunStaleOrder.payoutTxId).toBeNull(); + }); + + it('skips broadcast and persistence when the designation update fails, without escaping', async () => { + const { doPayout, dispatchSpy, repoUpdateSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + repoUpdateSpy.mockRejectedValueOnce(new Error('database 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(); - } + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.payoutTxId).toBeNull(); }); }); } @@ -149,15 +164,20 @@ function repoSaveEcho(payoutOrderRepo: PayoutOrderRepository): jest.SpyInstance return jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); } +function repoUpdateAffected(payoutOrderRepo: PayoutOrderRepository): jest.SpyInstance { + return jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); +} + function setupEvm(): DesignateBeforeBroadcastFixture { const payoutEvmService = mock(); const payoutOrderRepo = mock(); const dispatchFn = jest.fn(); + const repoUpdateSpy = repoUpdateAffected(payoutOrderRepo); const repoSaveSpy = repoSaveEcho(payoutOrderRepo); const strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, dispatchFn); - return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy: dispatchFn, repoSaveSpy }; + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy: dispatchFn, repoUpdateSpy, repoSaveSpy }; } function setupSolana(): DesignateBeforeBroadcastFixture { @@ -165,11 +185,12 @@ function setupSolana(): DesignateBeforeBroadcastFixture { const assetService = mock(); const payoutOrderRepo = mock(); const dispatchSpy = jest.spyOn(solanaService, 'sendNativeCoin'); + const repoUpdateSpy = repoUpdateAffected(payoutOrderRepo); const repoSaveSpy = repoSaveEcho(payoutOrderRepo); const strategy = new SolanaCoinStrategy(solanaService, assetService, payoutOrderRepo); - return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoUpdateSpy, repoSaveSpy }; } function setupTron(): DesignateBeforeBroadcastFixture { @@ -177,11 +198,12 @@ function setupTron(): DesignateBeforeBroadcastFixture { const assetService = mock(); const payoutOrderRepo = mock(); const dispatchSpy = jest.spyOn(tronService, 'sendNativeCoin'); + const repoUpdateSpy = repoUpdateAffected(payoutOrderRepo); const repoSaveSpy = repoSaveEcho(payoutOrderRepo); const strategy = new TronCoinStrategy(tronService, assetService, payoutOrderRepo); - return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoUpdateSpy, repoSaveSpy }; } function setupCardano(): DesignateBeforeBroadcastFixture { @@ -189,11 +211,12 @@ function setupCardano(): DesignateBeforeBroadcastFixture { const assetService = mock(); const payoutOrderRepo = mock(); const dispatchSpy = jest.spyOn(cardanoService, 'sendNativeCoin'); + const repoUpdateSpy = repoUpdateAffected(payoutOrderRepo); const repoSaveSpy = repoSaveEcho(payoutOrderRepo); const strategy = new CardanoCoinStrategy(cardanoService, assetService, payoutOrderRepo); - return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoUpdateSpy, repoSaveSpy }; } function setupIcp(): DesignateBeforeBroadcastFixture { @@ -201,11 +224,12 @@ function setupIcp(): DesignateBeforeBroadcastFixture { const assetService = mock(); const payoutOrderRepo = mock(); const dispatchSpy = jest.spyOn(internetComputerService, 'sendNativeCoin'); + const repoUpdateSpy = repoUpdateAffected(payoutOrderRepo); const repoSaveSpy = repoSaveEcho(payoutOrderRepo); const strategy = new InternetComputerCoinStrategy(internetComputerService, assetService, payoutOrderRepo); - return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoUpdateSpy, repoSaveSpy }; } function setupArkade(): DesignateBeforeBroadcastFixture { @@ -213,11 +237,12 @@ function setupArkade(): DesignateBeforeBroadcastFixture { const payoutOrderRepo = mock(); const assetService = mock(); const dispatchSpy = jest.spyOn(arkadeService, 'sendTransaction'); + const repoUpdateSpy = repoUpdateAffected(payoutOrderRepo); const repoSaveSpy = repoSaveEcho(payoutOrderRepo); const strategy = new ArkadeStrategy(arkadeService, payoutOrderRepo, assetService); - return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoUpdateSpy, repoSaveSpy }; } function setupSpark(): DesignateBeforeBroadcastFixture { @@ -225,11 +250,12 @@ function setupSpark(): DesignateBeforeBroadcastFixture { const payoutOrderRepo = mock(); const assetService = mock(); const dispatchSpy = jest.spyOn(sparkService, 'sendTransaction'); + const repoUpdateSpy = repoUpdateAffected(payoutOrderRepo); const repoSaveSpy = repoSaveEcho(payoutOrderRepo); const strategy = new SparkStrategy(sparkService, payoutOrderRepo, assetService); - return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoUpdateSpy, repoSaveSpy }; } function setupLightning(): DesignateBeforeBroadcastFixture { @@ -238,45 +264,38 @@ function setupLightning(): DesignateBeforeBroadcastFixture { const payoutOrderRepo = mock(); jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); const dispatchSpy = jest.spyOn(payoutLightningService, 'sendPayment'); + const repoUpdateSpy = repoUpdateAffected(payoutOrderRepo); const repoSaveSpy = repoSaveEcho(payoutOrderRepo); const strategy = new LightningStrategy(assetService, payoutLightningService, payoutOrderRepo); - return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoUpdateSpy, 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', () => { @@ -322,7 +341,8 @@ describe('Payout designate-before-broadcast', () => { expect(order.payoutTxId).toBeNull(); expect(dispatchSpy).toHaveBeenCalledTimes(1); expect(rollbackSpy).not.toHaveBeenCalled(); - expect(repoSaveSpy).toHaveBeenCalledTimes(1); + // designation is a conditional UPDATE, and the broadcast exception path persists nothing + expect(repoSaveSpy).not.toHaveBeenCalled(); }); it('CardanoStrategy: empty-tx-hash PayoutBroadcastException leaves PAYOUT_DESIGNATED (no rollback, no rebroadcast)', async () => { @@ -337,7 +357,8 @@ describe('Payout designate-before-broadcast', () => { expect(order.payoutTxId).toBeNull(); expect(dispatchSpy).toHaveBeenCalledTimes(1); expect(rollbackSpy).not.toHaveBeenCalled(); - expect(repoSaveSpy).toHaveBeenCalledTimes(1); + // designation is a conditional UPDATE, and the broadcast exception path persists nothing + expect(repoSaveSpy).not.toHaveBeenCalled(); }); }); }); 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 index a7c93c5dc2..431ba77ccc 100644 --- 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 @@ -33,6 +33,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { payoutOrderRepo = mock(); pricingService = mock(); dispatchFn = jest.fn(); + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); 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 @@ -116,7 +117,53 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { 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 + expect(payoutOrderRepo.update).toHaveBeenNthCalledWith( + 1, + { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OOG' }, + { status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }, + ); + expect(payoutOrderRepo.update).toHaveBeenNthCalledWith( + 2, + { id: order.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // pending only; rollback and designation use UPDATE + }); + + it('failed, out-of-gas + stale rollback: skips retry when the exact pending transaction changed', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OOG' }); + const status: PayoutTxStatus = { state: 'failed', isOutOfGas: true }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValueOnce({ affected: 0 } as any); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutOrderRepo.update).toHaveBeenCalledWith( + { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OOG' }, + { status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }, + ); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_OOG'); + }); + + it('failed, out-of-gas + rollback update error: skips retry and leaves the order unchanged', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OOG' }); + const status: PayoutTxStatus = { state: 'failed', isOutOfGas: true }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + jest.spyOn(payoutOrderRepo, 'update').mockRejectedValueOnce(new Error('database unavailable')); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_OOG'); }); it('(d) expired in mempool + retryable: keeps payoutTxId, skips re-designation and reuses the nonce', async () => { @@ -136,6 +183,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { expect(payoutEvmService.isTxExpired).toHaveBeenCalledWith('TX_OLD'); // Guard: payoutTxId was never cleared, so designateBeforeBroadcast skips re-designation. expect(designateSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.update).not.toHaveBeenCalled(); expect(rollbackSpy).not.toHaveBeenCalled(); expect(completeSpy).not.toHaveBeenCalled(); expect(payoutEvmService.getTxNonce).toHaveBeenCalledWith('TX_OLD'); // nonce reuse @@ -143,7 +191,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { 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 + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the final pending save; no designation update on re-entry }); it('(e) pending: no status change, no completion, no designation, no rollback, no dispatch', async () => { @@ -200,6 +248,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { const payoutEvmService = mock(); payoutOrderRepo = mock(); dispatchFn = jest.fn(); + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, dispatchFn); @@ -221,7 +270,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { 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 + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // rollback save; designation uses UPDATE }); it('broadcast-boundary error (PayoutBroadcastException): stays PAYOUT_DESIGNATED, no rollback, no retryCount increment', async () => { @@ -234,7 +283,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { 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 + expect(repoSaveSpy).not.toHaveBeenCalled(); }); it('re-entry (payoutTxId already set) + plain error: never rolls back, preserving nonce reuse', async () => { @@ -270,7 +319,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { 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 + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // rollback save; designation uses UPDATE }); it('pre-broadcast error at the retry cap: stops rolling back so the order escalates to PAYOUT_UNCERTAIN', async () => { @@ -287,7 +336,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { 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 + expect(repoSaveSpy).not.toHaveBeenCalled(); }); it('successful payout resets a previously tracked retry count', async () => { 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 index fc32ca84b3..69472710e9 100644 --- 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 @@ -81,6 +81,10 @@ function repoSaveEcho(payoutOrderRepo: PayoutOrderRepository): void { jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); } +function repoUpdateAffected(payoutOrderRepo: PayoutOrderRepository): void { + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); +} + function makeConfirmedCoinOrder(): PayoutOrder { return createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, @@ -166,6 +170,7 @@ describe('Payout non-EVM leaf strategies', () => { const payoutOrderRepo = mock(); const feeAsset = createCustomAsset({ name: 'ADA' }); const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoUpdateAffected(payoutOrderRepo); repoSaveEcho(payoutOrderRepo); const strategy = new CardanoCoinStrategy(cardanoService, assetService, payoutOrderRepo); @@ -198,6 +203,7 @@ describe('Payout non-EVM leaf strategies', () => { const payoutOrderRepo = mock(); const feeAsset = createCustomAsset({ name: 'ADA' }); const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoUpdateAffected(payoutOrderRepo); repoSaveEcho(payoutOrderRepo); const strategy = new CardanoTokenStrategy(cardanoService, assetService, payoutOrderRepo); @@ -232,6 +238,7 @@ describe('Payout non-EVM leaf strategies', () => { const payoutOrderRepo = mock(); const feeAsset = createCustomAsset({ name: 'SOL' }); const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoUpdateAffected(payoutOrderRepo); repoSaveEcho(payoutOrderRepo); const strategy = new SolanaCoinStrategy(solanaService, assetService, payoutOrderRepo); @@ -264,6 +271,7 @@ describe('Payout non-EVM leaf strategies', () => { const payoutOrderRepo = mock(); const feeAsset = createCustomAsset({ name: 'SOL' }); const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoUpdateAffected(payoutOrderRepo); repoSaveEcho(payoutOrderRepo); const strategy = new SolanaTokenStrategy(solanaService, assetService, payoutOrderRepo); @@ -298,6 +306,7 @@ describe('Payout non-EVM leaf strategies', () => { const payoutOrderRepo = mock(); const feeAsset = createCustomAsset({ name: 'TRX' }); const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoUpdateAffected(payoutOrderRepo); repoSaveEcho(payoutOrderRepo); const strategy = new TronCoinStrategy(tronService, assetService, payoutOrderRepo); @@ -330,6 +339,7 @@ describe('Payout non-EVM leaf strategies', () => { const payoutOrderRepo = mock(); const feeAsset = createCustomAsset({ name: 'TRX' }); const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoUpdateAffected(payoutOrderRepo); repoSaveEcho(payoutOrderRepo); const strategy = new TronTokenStrategy(tronService, assetService, payoutOrderRepo); @@ -364,6 +374,7 @@ describe('Payout non-EVM leaf strategies', () => { const payoutOrderRepo = mock(); const feeAsset = createCustomAsset({ name: 'ICP' }); const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoUpdateAffected(payoutOrderRepo); repoSaveEcho(payoutOrderRepo); const strategy = new InternetComputerCoinStrategy(icpService, assetService, payoutOrderRepo); @@ -396,6 +407,7 @@ describe('Payout non-EVM leaf strategies', () => { const payoutOrderRepo = mock(); const feeAsset = createCustomAsset({ name: 'ICP' }); const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoUpdateAffected(payoutOrderRepo); repoSaveEcho(payoutOrderRepo); const strategy = new InternetComputerTokenStrategy(icpService, assetService, payoutOrderRepo); 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 index b1d113daf7..99810dc352 100644 --- 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 @@ -25,6 +25,7 @@ describe('ZanoStrategy', () => { beforeEach(() => { notificationService = mock(); payoutOrderRepo = mock(); + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); payoutZanoService = mock(); assetService = mock(); 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 index 408356b8a4..cf3712c15b 100644 --- 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 @@ -24,25 +24,45 @@ describe('PayoutStrategy (base)', () => { strategy = new TestStrategyWrapper(); }); - it('designates and persists the order when payoutTxId is not yet set', async () => { + it('conditionally designates the order when payoutTxId is not yet set', async () => { const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); const designateSpy = jest.spyOn(order, 'designatePayout'); + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); - await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + const result = await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + expect(result).toBe(true); expect(designateSpy).toHaveBeenCalledTimes(1); - expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); - expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + expect(payoutOrderRepo.update).toHaveBeenCalledWith( + { id: order.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); }); - it('does not designate nor persist the order when payoutTxId is already set', async () => { + it('returns false without changing the entity when the conditional update loses the race', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 0 } as any); + + const result = await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + + expect(result).toBe(false); + expect(designateSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); + + it('keeps payoutTxId re-entry eligible without designation or persistence', async () => { const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'OLD_TX' }); const designateSpy = jest.spyOn(order, 'designatePayout'); - await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + const result = await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + expect(result).toBe(true); expect(designateSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.update).not.toHaveBeenCalled(); expect(payoutOrderRepo.save).not.toHaveBeenCalled(); expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); }); @@ -239,7 +259,7 @@ class TestStrategyWrapper extends PayoutStrategy { throw new Error('Method not implemented.'); } - async callDesignateBeforeBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { + async callDesignateBeforeBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { return this.designateBeforeBroadcast(order, repo); } 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 061a63847f..25abe8b117 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts @@ -45,7 +45,7 @@ export class ArkadeStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + if (!(await this.designateBeforeBroadcast(order, this.payoutOrderRepo))) continue; const txId = await this.dispatchPayout(order); order.pendingPayout(txId); 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 6522db1756..da9ddc5291 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 @@ -141,18 +141,20 @@ export abstract class BitcoinBasedStrategy extends PayoutStrategy { if (orders.some((o) => o.payoutTxId) && !DisabledProcess(Process.TX_SPEEDUP)) throw new Error(`Transaction speedup is not implemented for ${this.blockchain}`); + const designated = await this.designatePayout(orders); + if (!designated.length) return; + try { - const payout = this.aggregatePayout(orders); + const payout = this.aggregatePayout(designated); - await this.designatePayout(orders); - payoutTxId = await this.dispatchPayout(context, payout, orders[0].asset); + payoutTxId = await this.dispatchPayout(context, payout, designated[0].asset); } catch (e) { this.logger.error( - `Error on sending ${orders[0].asset.name} for payout. Order ID(s): ${orders.map((o) => o.id)}:`, + `Error on sending ${designated[0].asset.name} for payout. Order ID(s): ${designated.map((o) => o.id)}:`, e, ); - await this.trackPayoutFailure(orders, e); + await this.trackPayoutFailure(designated, 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 @@ -161,12 +163,12 @@ export abstract class BitcoinBasedStrategy extends PayoutStrategy { // pre-broadcast and safe to roll back for auto-retry. if (e instanceof PayoutBroadcastException) throw e; - await this.rollbackPayoutDesignation(orders); + await this.rollbackPayoutDesignation(designated); return; } - for (const order of orders) { + for (const order of designated) { try { order.resetPayoutRetry(); const paidOrder = order.pendingPayout(payoutTxId); @@ -196,11 +198,12 @@ export abstract class BitcoinBasedStrategy extends PayoutStrategy { return Util.fixRoundingMismatch(roundedPayouts, 'amount', payoutTotal); } - protected async designatePayout(orders: PayoutOrder[]): Promise { + protected async designatePayout(orders: PayoutOrder[]): Promise { + const designated: PayoutOrder[] = []; for (const order of orders) { - order.designatePayout(); - await this.payoutOrderRepo.save(order); + if (await this.claimForBroadcast(order, this.payoutOrderRepo)) designated.push(order); } + return designated; } protected async rollbackPayoutDesignation(orders: PayoutOrder[]): Promise { 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 36c1611ba2..55168802bd 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,7 +37,7 @@ export abstract class CardanoStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + if (!(await this.designateBeforeBroadcast(order, this.payoutOrderRepo))) continue; const txId = await this.dispatchPayout(order); order.pendingPayout(txId); 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 67057bd515..d0bc72bfc3 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 @@ -43,7 +43,7 @@ export abstract class EvmStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + if (!(await this.designateBeforeBroadcast(order, this.payoutOrderRepo))) continue; const txId = await this.dispatchPayout(order); order.resetPayoutRetry(); @@ -83,8 +83,7 @@ export abstract class EvmStrategy extends PayoutStrategy { this.logger.warn( `Payout order ${order.id} failed with out-of-gas (tx ${order.payoutTxId}), retrying with fresh nonce`, ); - order.rollbackPayout(); - await this.payoutOrderRepo.save(order); + if (!(await this.rollbackBroadcastForRetry(order, this.payoutOrderRepo))) continue; } else { // TX expired (not on-chain, not in mempool) - retry immediately, no gas costs incurred this.logger.info(`Payout order ${order.id} has expired TX (${order.payoutTxId}), retrying immediately`); 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 11e4adf5d9..6c878aca9c 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,7 +37,7 @@ export abstract class InternetComputerStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + if (!(await this.designateBeforeBroadcast(order, this.payoutOrderRepo))) continue; const txId = await this.dispatchPayout(order); order.pendingPayout(txId); 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 3a5646c719..1576b2ce2f 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 @@ -2,15 +2,17 @@ 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 { DfxLogger } from 'src/shared/services/dfx-logger'; 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 { PayoutOrder, PayoutOrderStatus } 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 { private _feeAsset: Asset; + private readonly designationLogger = new DfxLogger(PayoutStrategy); @Inject() protected readonly pricingService: PricingService; @Inject() private readonly registry: PayoutStrategyRegistry; @@ -49,14 +51,62 @@ export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { 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) { + // Atomically claim the order for this run (see designateBeforeBroadcast). A claim that fails — + // lost race (affected = 0) or a DB error on the UPDATE itself — leaves the order unclaimed and + // therefore re-selectable by the next cron run: skipping it here is the self-healing direction, + // while letting the error escape would run rollback/failure handling on an order this run does + // not own and stale-overwrite a concurrent run's state. + protected async claimForBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { + try { + const result = await repo.update( + { id: order.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + if (!result.affected) { + this.designationLogger.warn(`Skipping payout order ${order.id}: designation lost to a concurrent payout run`); + return false; + } + order.designatePayout(); - await repo.save(order); + return true; + } catch (e) { + this.designationLogger.warn( + `Skipping payout order ${order.id}: designation claim failed, order stays re-selectable`, + e, + ); + return false; + } + } + + // The payout cron lock expires after 1800 seconds, so payout runs can overlap. An atomic + // conditional transition prevents a stale run from broadcasting an order already claimed by + // another run or overwriting its newer payout state; a plain entity save cannot enforce this. + // A re-entry with payoutTxId already set (EVM speedup/expired-retry) keeps its existing status. + protected async designateBeforeBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { + if (order.payoutTxId) return true; + return this.claimForBroadcast(order, repo); + } + + // Conditionally releases a broadcast order for a fresh protected retry. The WHERE clause pins + // both status and the exact payoutTxId this run saw, so a stale run cannot roll back an order a + // concurrent run has already re-claimed or re-broadcast (that would null the fresh payoutTxId + // and re-open the order for a second broadcast). + protected async rollbackBroadcastForRetry(order: PayoutOrder, repo: PayoutOrderRepository): Promise { + try { + const result = await repo.update( + { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: order.payoutTxId }, + { status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }, + ); + if (!result.affected) { + this.designationLogger.warn(`Skipping payout retry for order ${order.id}: state changed concurrently`); + return false; + } + + order.rollbackPayout(); + return true; + } catch (e) { + this.designationLogger.warn(`Skipping payout retry for order ${order.id}: rollback claim failed`, e); + return false; } } 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 29f760faaa..716f6efa74 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,7 +37,7 @@ export abstract class SolanaStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + if (!(await this.designateBeforeBroadcast(order, this.payoutOrderRepo))) continue; const txId = await this.dispatchPayout(order); order.pendingPayout(txId); 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 729f7a3c89..42902ab975 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,7 +37,7 @@ export abstract class TronStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + if (!(await this.designateBeforeBroadcast(order, this.payoutOrderRepo))) continue; const txId = await this.dispatchPayout(order); order.pendingPayout(txId); 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 491812c5d8..819cb6f0a0 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts @@ -39,7 +39,7 @@ export class LightningStrategy extends PayoutStrategy { if (await this.isHealthy()) { for (const order of orders) { try { - await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + if (!(await this.designateBeforeBroadcast(order, this.payoutOrderRepo))) continue; const address = order.destinationAddress; const amount = order.amount; 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 08cec3d8f6..4a8958eeb0 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts @@ -45,7 +45,7 @@ export class SparkStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + if (!(await this.designateBeforeBroadcast(order, this.payoutOrderRepo))) continue; const txId = await this.dispatchPayout(order); order.pendingPayout(txId); From f739572ce31f9011f240508895f8f161da64ae87 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:37:18 +0200 Subject: [PATCH 02/19] fix(accounting): book buy_crypto returns from the charged-back row, not the incoming one (#4261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bank_tx consumer resolved a BUY_CRYPTO_RETURN's owed CHF from tx.buyCrypto, which is the inverse of buy_crypto.bankTx — the incoming funding payment, always undefined for a return row. The charged-back buy_crypto hangs off buy_crypto.chargebackBankTx (tx.buyCryptoChargeback), so every return threw 'without buyCrypto.amountInChf', froze the bank_tx watermark and stalled all later bank_tx bookings — regardless of pricing. Read tx.buyCryptoChargeback in buyCryptoOwedChf and load the relation in both the forward and content-scan batch loaders. The incoming BUY_CRYPTO path keeps reading tx.buyCrypto, which is correct there. The return still closes the owed the forward path opened — no skip, no accounting change. The spec masked the bug by wiring the return mock's buyCrypto field directly; the production row has buyCrypto undefined and buyCryptoChargeback set. All return cases now wire buyCryptoChargeback, plus a regression test with buyCrypto undefined. --- .../__tests__/bank-tx.consumer.spec.ts | 47 ++++++++++++++----- .../services/consumers/bank-tx.consumer.ts | 16 ++++--- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts index 389562e266..aa028731e2 100644 --- a/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts +++ b/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts @@ -264,7 +264,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'EUR-IBAN', amount: 10000, - buyCrypto, + buyCryptoChargeback: buyCrypto, }), ]); await consumer.process(); @@ -282,6 +282,29 @@ describe('BankTxConsumer', () => { expect(cents(legs)).toBe(0); }); + it('books BUY_CRYPTO_RETURN from buyCryptoChargeback when the incoming buyCrypto relation is undefined', async () => { + const buyCryptoChargeback = { id: 76, amountInChf: 1000, totalFeeAmountChf: 0 } as any; + mockBatch([ + bankTx({ + type: BankTxType.BUY_CRYPTO_RETURN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 1000, + buyCrypto: undefined, + buyCryptoChargeback, + }), + ]); + + await expect(consumer.process()).resolves.toBeUndefined(); + + expect(booked).toHaveLength(1); + const legs = booked[0].legs; + expect(legs).toHaveLength(2); + expect(legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-owed').amountChf).toBe(1000); + expect(legs.find((l) => l.account === chfBankAccount).amountChf).toBe(-1000); + expect(cents(legs)).toBe(0); + }); + it('books BUY_CRYPTO_RETURN on a CHF bank as a 2-leg tx (drift 0, no plug)', async () => { // CHF account: amount == amountInChf, completion CHF = 1000 − 0 = 1000, bank-Cr = −1000 → plug 0 → 2-leg const buyCrypto = { id: 78, amountInChf: 1000, totalFeeAmountChf: 0 } as any; @@ -291,7 +314,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'CHF-IBAN', amount: 1000, - buyCrypto, + buyCryptoChargeback: buyCrypto, }), ]); await consumer.process(); @@ -918,7 +941,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'EUR-IBAN', amount: 50000, // EUR-mark 0.95 → bank-Cr −47500 - buyCrypto, + buyCryptoChargeback: buyCrypto, }), ]); await consumer.process(); @@ -1086,9 +1109,9 @@ describe('BankTxConsumer', () => { expect(booked[0].valueDate).toBe(created); }); - // §4.2a buyCryptoOwedChf guard: a BUY_CRYPTO_RETURN whose buyCrypto.amountInChf is null AND no cutover opening → + // §4.2a buyCryptoOwedChf guard: a BUY_CRYPTO_RETURN whose buyCryptoChargeback.amountInChf is null AND no cutover → // throws (source line 279) → failure-isolation, nothing booked, watermark not advanced. - it('throws (failure-isolation) on BUY_CRYPTO_RETURN without buyCrypto.amountInChf and no cutover (line 279)', async () => { + it('throws (failure-isolation) on BUY_CRYPTO_RETURN without buyCryptoChargeback.amountInChf and no cutover (line 279)', async () => { const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); const buyCrypto = { id: 91, amountInChf: null } as any; // no completion anchor; default mocks → no cutover opening mockBatch([ @@ -1098,7 +1121,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'CHF-IBAN', amount: 1000, - buyCrypto, + buyCryptoChargeback: buyCrypto, }), ]); await consumer.process(); @@ -1107,7 +1130,7 @@ describe('BankTxConsumer', () => { expect(setSpy).not.toHaveBeenCalled(); // throw → watermark stays put }); - // §4.2a buyCryptoOwedChf `totalFeeAmountChf ?? 0` null side (source line 280): a return whose buyCrypto has + // §4.2a buyCryptoOwedChf `totalFeeAmountChf ?? 0` null side (source line 280): a return whose charged-back buy_crypto has // amountInChf set but totalFeeAmountChf null → owed = amountInChf − 0 (CHF account → no fee subtracted, no plug). it('BUY_CRYPTO_RETURN owed-Dr uses amountInChf − 0 when totalFeeAmountChf is null (line 280 null side)', async () => { const buyCrypto = { id: 93, amountInChf: 1000, totalFeeAmountChf: null } as any; @@ -1118,7 +1141,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'CHF-IBAN', amount: 1000, - buyCrypto, + buyCryptoChargeback: buyCrypto, }), ]); await consumer.process(); @@ -1151,7 +1174,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'CHF-IBAN', amount: 1000, - buyCrypto, + buyCryptoChargeback: buyCrypto, }), ]); await consumer.process(); @@ -1678,7 +1701,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'EUR-IBAN', amount: 10000, - buyCrypto, + buyCryptoChargeback: buyCrypto, chargeAmount: 10, chargeCurrency: 'EUR', chargeAmountChf: null, // inline @@ -1713,7 +1736,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'EUR-IBAN', amount: 10000, - buyCrypto, + buyCryptoChargeback: buyCrypto, chargeAmount: 10, chargeCurrency: 'EUR', chargeAmountChf: null, // inline @@ -1748,7 +1771,7 @@ describe('BankTxConsumer', () => { creditDebitIndicator: BankTxIndicator.DEBIT, accountIban: 'EUR-IBAN', amount: 10000, - buyCrypto, + buyCryptoChargeback: buyCrypto, chargeAmount: 10, chargeCurrency: 'EUR', chargeAmountChf: null, diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index 7a39ed1602..27c7797603 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -88,7 +88,7 @@ export class BankTxConsumer { SOURCE_TYPE, afterForward, this.bankTxRepo, - { buyCrypto: true }, + { buyCrypto: true, buyCryptoChargeback: true }, async (tx: BankTx) => { const t = tx.bookingDate ?? tx.created; await this.reconcileBooking( @@ -104,7 +104,7 @@ export class BankTxConsumer { // DBIT only after 5 min (analog assignTransactions); settlement = bookingDate ?? created (§4.2) const batch = await this.bankTxRepo.find({ where: { id: MoreThan(watermark.lastProcessedId), created: LessThan(Util.minutesBefore(5)) }, - relations: { buyCrypto: true }, + relations: { buyCrypto: true, buyCryptoChargeback: true }, order: { id: 'ASC' }, take: Config.ledger.backfillBatchSize, }); @@ -283,12 +283,16 @@ export class BankTxConsumer { // the CHF the buyCrypto-owed was opened with: §4.6 completion (amountInChf − totalFeeAmountChf), or — for a // cutover-straddling buy_crypto whose owed was opened by the cutover (§6.1 per-row marker) — the opening CHF. private async buyCryptoOwedChf(tx: BankTx): Promise { - const openingChf = await this.cutoverOwedOpeningChf(tx.buyCrypto?.id); + // the charged-back buy_crypto hangs off buy_crypto.chargebackBankTx (→ tx.buyCryptoChargeback), NOT the incoming + // buy_crypto.bankTx (→ tx.buyCrypto, which is undefined for a RETURN row); a return closes the owed of the row it + // charges back + const openingChf = await this.cutoverOwedOpeningChf(tx.buyCryptoChargeback?.id); if (openingChf != null) return openingChf; // cutover-straddling: debit the exact opening CHF anchor - const amountInChf = tx.buyCrypto?.amountInChf; - if (amountInChf == null) throw new Error(`bank_tx ${tx.id} BUY_CRYPTO_RETURN without buyCrypto.amountInChf`); - return Util.round(amountInChf - (tx.buyCrypto?.totalFeeAmountChf ?? 0), 2); // completion CHF (additive null-strategy) + const amountInChf = tx.buyCryptoChargeback?.amountInChf; + if (amountInChf == null) + throw new Error(`bank_tx ${tx.id} BUY_CRYPTO_RETURN without buyCryptoChargeback.amountInChf`); + return Util.round(amountInChf - (tx.buyCryptoChargeback?.totalFeeAmountChf ?? 0), 2); // completion CHF (additive null-strategy) } // looks up the cutover per-row owed-opening leg CHF (§6.1 marker `${snapshotLogId}:buy_crypto-owed:${id}`); the From 0d3ed02017f6cc0e29c52c6cfaaff924689c0965 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:56:13 -0300 Subject: [PATCH 03/19] fix(accounting): name the source booking in the native-imbalance error (#4258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the prod cutover the same-asset native-imbalance error fires in batches (USDT/BTC/CHF/BNB/XMR, fee-shaped amounts), but the line carries no reference — the producing consumer cannot be identified from logs, and the ledger tables are not debug-queryable. Attach sourceType/sourceId/seq and the per-leg amounts so every occurrence names its producer; the missing-fee-leg fix can then target the right consumer. --- .../services/__tests__/ledger-booking.service.spec.ts | 1 + .../core/accounting/services/ledger-booking.service.ts | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts index 4400d76d62..af3c893dd7 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -232,6 +232,7 @@ describe('LedgerBookingService', () => { }); expect(logSpy).toHaveBeenCalled(); // pure same-asset transfer with native imbalance → logged + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('source exchange_tx 6 seq 0')); // names the producer }); it('reverses a tx with inverted legs and the next free seq', async () => { diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index 8f128cb27c..c27e72ecc9 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -63,7 +63,7 @@ export class LedgerBookingService { const legs = input.legs.map((leg) => this.prepareLeg(leg)); await this.appendRoundingLeg(legs); - this.checkNativeBalance(legs); + this.checkNativeBalance(legs, input); const amountChfSum = legs.reduce((sum, leg) => sum + leg.amountChfCents, 0); @@ -388,7 +388,7 @@ export class LedgerBookingService { * per currency must be 0. A leg on any non-ASSET/TRANSIT account makes the native one-sidedness correct * (value-boundary booking) → no native check (§2.3 Major R9-2). */ - private checkNativeBalance(legs: LedgerLeg[]): void { + private checkNativeBalance(legs: LedgerLeg[], input: LedgerTxInput): void { const onlyAssetTransit = legs.every( (leg) => leg.account.type === AccountType.ASSET || leg.account.type === AccountType.TRANSIT, ); @@ -398,8 +398,10 @@ export class LedgerBookingService { for (const [currency, currencyLegs] of byCurrency.entries()) { const nativeSum = currencyLegs.reduce((acc, leg) => acc + leg.amount, 0); if (Math.abs(nativeSum) > NATIVE_BALANCE_TOLERANCE) { + const accounts = currencyLegs.map((leg) => `${leg.account.name} ${leg.amount}`).join(', '); this.logger.error( - `Ledger same-asset transfer native imbalance for currency ${currency}: ${nativeSum} (programming error)`, + `Ledger same-asset transfer native imbalance for currency ${currency}: ${nativeSum} ` + + `(source ${input.sourceType} ${input.sourceId} seq ${input.seq}; legs: ${accounts}) (programming error)`, ); } } From f246c08e8db741e93aee3f9968f4143df849f46b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:57:27 +0200 Subject: [PATCH 04/19] fix(payout): bring the EVM expired-tx retry under the designate-before-broadcast protection (#4237) The expired-retry path re-entered doPayout with payoutTxId still set, assuming nonce reuse would replace the pending tx. For an expired tx the hash has vanished, getTxNonce resolves undefined and dispatch draws a fresh nonce - the retry was an independent second transaction outside the designate-before- broadcast protection. It now goes through the shared rollbackBroadcastForRetry release like the out-of-gas path, so a crash window stays closed and an ambiguous failure escalates instead of silently looping. The release also records every replaced tx hash in a new append-only releasedPayoutTxIds column (migration included), so a PayoutUncertain investigation can still reconstruct the vanished hash from the DB after the retry nulls payoutTxId. The column is exposed via the debug allowlist. Closes #4229 --- ...84215858000-AddPayoutOrderReleasedTxIds.js | 26 ++++ src/subdomains/generic/gs/dto/gs.dto.ts | 1 + .../__mocks__/payout-order.entity.mock.ts | 2 + .../payout/entities/payout-order.entity.ts | 9 ++ ...esignate-before-broadcast.strategy.spec.ts | 2 +- .../payout/__tests__/payout-evm-retry.spec.ts | 137 ++++++++++++++++-- .../payout/impl/base/evm.strategy.ts | 12 +- .../payout/impl/base/payout.strategy.ts | 14 +- 8 files changed, 181 insertions(+), 22 deletions(-) create mode 100644 migration/1784215858000-AddPayoutOrderReleasedTxIds.js diff --git a/migration/1784215858000-AddPayoutOrderReleasedTxIds.js b/migration/1784215858000-AddPayoutOrderReleasedTxIds.js new file mode 100644 index 0000000000..faa8190e6c --- /dev/null +++ b/migration/1784215858000-AddPayoutOrderReleasedTxIds.js @@ -0,0 +1,26 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddPayoutOrderReleasedTxIds1784215858000 { + name = 'AddPayoutOrderReleasedTxIds1784215858000' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "payout_order" ADD "releasedPayoutTxIds" character varying(2048)`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "payout_order" DROP COLUMN "releasedPayoutTxIds"`); + } +} diff --git a/src/subdomains/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index 2104120afe..844247a7d6 100644 --- a/src/subdomains/generic/gs/dto/gs.dto.ts +++ b/src/subdomains/generic/gs/dto/gs.dto.ts @@ -996,6 +996,7 @@ export const DebugAllowedColumns: Record = { 'preparationFeeAmount', 'preparationFeeAmountChf', 'preparationFeeAssetId', + 'releasedPayoutTxIds', 'retryCount', 'status', 'transferTxId', diff --git a/src/subdomains/supporting/payout/entities/__mocks__/payout-order.entity.mock.ts b/src/subdomains/supporting/payout/entities/__mocks__/payout-order.entity.mock.ts index 9ed43af7bd..c8f11bbe44 100644 --- a/src/subdomains/supporting/payout/entities/__mocks__/payout-order.entity.mock.ts +++ b/src/subdomains/supporting/payout/entities/__mocks__/payout-order.entity.mock.ts @@ -19,6 +19,7 @@ export function createCustomPayoutOrder(customValues: Partial): Pay transferTxId, payoutTxId, retryCount, + releasedPayoutTxIds, } = customValues; const keys = Object.keys(customValues); @@ -35,6 +36,7 @@ export function createCustomPayoutOrder(customValues: Partial): Pay entity.transferTxId = keys.includes('transferTxId') ? transferTxId : 'TTX_01'; entity.payoutTxId = keys.includes('payoutTxId') ? payoutTxId : 'PTX_01'; entity.retryCount = keys.includes('retryCount') ? retryCount : 0; + entity.releasedPayoutTxIds = keys.includes('releasedPayoutTxIds') ? releasedPayoutTxIds : undefined; return entity; } diff --git a/src/subdomains/supporting/payout/entities/payout-order.entity.ts b/src/subdomains/supporting/payout/entities/payout-order.entity.ts index bd5a3d2f1f..b306c0f6be 100644 --- a/src/subdomains/supporting/payout/entities/payout-order.entity.ts +++ b/src/subdomains/supporting/payout/entities/payout-order.entity.ts @@ -79,6 +79,15 @@ export class PayoutOrder extends IEntity { @Column({ type: 'timestamp', nullable: true }) lastAttemptDate?: Date; + // Append-only history of tx hashes released for a protected retry (expired/OOG). The release + // nulls payoutTxId so the order can re-enter the designation flow - without this record the + // replaced hash would not be reconstructable from the DB, and it is the primary evidence when + // investigating whether a vanished tx confirmed after all. Known limit: a stale full-entity + // save from an overlapping cron run (pre-existing write channel) can overwrite this column + // like any other field; the conditional release itself is pinned and cannot lose entries. + @Column({ length: 2048, nullable: true }) + releasedPayoutTxIds?: string; + pendingPreparation(transferTxId: string): this { this.transferTxId = transferTxId; this.status = PayoutOrderStatus.PREPARATION_PENDING; 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 index dfde6cf2b7..1621e6c69f 100644 --- 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 @@ -93,7 +93,7 @@ function runDesignateBeforeBroadcastSuite( expect(repoSaveSpy).not.toHaveBeenCalled(); }); - it('does NOT re-designate when payoutTxId is already set (speedup/expired-retry path)', async () => { + it('does NOT re-designate when payoutTxId is already set (manual speedup path)', async () => { const { doPayout, dispatchSpy, repoUpdateSpy, repoSaveSpy } = setup(); const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'OLD_TX' }); const designateSpy = jest.spyOn(order, 'designatePayout'); 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 index 431ba77ccc..e61985d237 100644 --- 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 @@ -120,13 +120,18 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { expect(payoutOrderRepo.update).toHaveBeenNthCalledWith( 1, { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OOG' }, - { status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }, + { + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + releasedPayoutTxIds: 'TX_OOG', + }, ); expect(payoutOrderRepo.update).toHaveBeenNthCalledWith( 2, { id: order.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, ); + expect(order.releasedPayoutTxIds).toBe('TX_OOG'); expect(repoSaveSpy).toHaveBeenCalledTimes(1); // pending only; rollback and designation use UPDATE }); @@ -141,7 +146,11 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { expect(payoutOrderRepo.update).toHaveBeenCalledWith( { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OOG' }, - { status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }, + { + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + releasedPayoutTxIds: 'TX_OOG', + }, ); expect(rollbackSpy).not.toHaveBeenCalled(); expect(dispatchFn).not.toHaveBeenCalled(); @@ -166,35 +175,101 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { expect(order.payoutTxId).toBe('TX_OOG'); }); - 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' }); + it('(d) expired + retryable: persists rollback, re-designates, then dispatches with a fresh nonce', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'TX_OLD', + releasedPayoutTxIds: 'TX_RELEASED_FIRST', + }); 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'); + const doPayoutSpy = jest.spyOn(strategy, 'doPayout'); + const repoUpdateSpy = jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); + const persistedStates: { status: PayoutOrderStatus; payoutTxId: string | null }[] = []; + repoSaveSpy.mockImplementation(async (o: PayoutOrder) => { + persistedStates.push({ status: o.status, payoutTxId: o.payoutTxId }); + return o; + }); 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(payoutOrderRepo.update).not.toHaveBeenCalled(); - expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoUpdateSpy).toHaveBeenNthCalledWith( + 1, + { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OLD' }, + { + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + releasedPayoutTxIds: 'TX_RELEASED_FIRST;TX_OLD', + }, + ); + expect(repoUpdateSpy).toHaveBeenNthCalledWith( + 2, + { id: order.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(designateSpy).toHaveBeenCalledTimes(1); expect(completeSpy).not.toHaveBeenCalled(); - expect(payoutEvmService.getTxNonce).toHaveBeenCalledWith('TX_OLD'); // nonce reuse + expect(persistedStates).toEqual([{ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_NEW_EXPIRED' }]); + expect(repoUpdateSpy.mock.invocationCallOrder[0]).toBeLessThan(doPayoutSpy.mock.invocationCallOrder[0]); + expect(repoUpdateSpy.mock.invocationCallOrder[1]).toBeLessThan(dispatchFn.mock.invocationCallOrder[0]); + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); expect(dispatchFn).toHaveBeenCalledTimes(1); - expect(dispatchFn).toHaveBeenCalledWith(order, 7); + expect(dispatchFn).toHaveBeenCalledWith(order, undefined); expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); expect(order.payoutTxId).toBe('TX_NEW_EXPIRED'); - expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the final pending save; no designation update on re-entry + expect(order.releasedPayoutTxIds).toBe('TX_RELEASED_FIRST;TX_OLD'); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // pending only; rollback and designation use UPDATE }); - it('(e) pending: no status change, no completion, no designation, no rollback, no dispatch', async () => { + it('(e) expired retry + broadcast-boundary error: stays designated without a rollback save', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OLD' }); + order.updated = new Date(Date.now() - 2 * 60 * 60 * 1000); + const status: PayoutTxStatus = { state: 'pending' }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + jest.spyOn(payoutEvmService, 'isTxExpired').mockResolvedValue(true); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + const rollbackDesignationSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const repoUpdateSpy = jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); + dispatchFn.mockRejectedValue(new PayoutBroadcastException('tx may already be in-flight')); + + await strategy.checkPayoutCompletionData([order]); + + expect(repoUpdateSpy).toHaveBeenNthCalledWith( + 1, + { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OLD' }, + { + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + releasedPayoutTxIds: 'TX_OLD', + }, + ); + expect(repoUpdateSpy).toHaveBeenNthCalledWith( + 2, + { id: order.id, status: PayoutOrderStatus.PREPARATION_CONFIRMED }, + { status: PayoutOrderStatus.PAYOUT_DESIGNATED }, + ); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(designateSpy).toHaveBeenCalledTimes(1); + expect(rollbackDesignationSpy).not.toHaveBeenCalled(); + expect(repoUpdateSpy.mock.invocationCallOrder[1]).toBeLessThan(dispatchFn.mock.invocationCallOrder[0]); + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBeNull(); + expect(order.releasedPayoutTxIds).toBe('TX_OLD'); + expect(order.retryCount).toBe(0); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('(f) 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' }; @@ -215,6 +290,38 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { expect(order.payoutTxId).toBe('TX_PENDING'); expect(repoSaveSpy).not.toHaveBeenCalled(); }); + + it('(g) concurrent state change: skips retry when conditional rollback loses', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OLD' }); + order.updated = new Date(Date.now() - 2 * 60 * 60 * 1000); + const status: PayoutTxStatus = { state: 'pending' }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + jest.spyOn(payoutEvmService, 'isTxExpired').mockResolvedValue(true); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const doPayoutSpy = jest.spyOn(strategy, 'doPayout'); + const repoUpdateSpy = jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 0 } as any); + + await strategy.checkPayoutCompletionData([order]); + + expect(repoUpdateSpy).toHaveBeenCalledTimes(1); + expect(repoUpdateSpy).toHaveBeenCalledWith( + { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OLD' }, + { + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + releasedPayoutTxIds: 'TX_OLD', + }, + ); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(designateSpy).not.toHaveBeenCalled(); + expect(doPayoutSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_OLD'); + expect(order.releasedPayoutTxIds).toBeUndefined(); + }); }); describe('EvmStrategy #canRetryFailedPayout(...)', () => { @@ -286,7 +393,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { expect(repoSaveSpy).not.toHaveBeenCalled(); }); - it('re-entry (payoutTxId already set) + plain error: never rolls back, preserving nonce reuse', async () => { + it('manual speedup re-entry + plain error: never rolls back, preserving nonce reuse', async () => { const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: 'OLD_TX', @@ -298,7 +405,7 @@ describe('Payout EVM retry x designate-before-broadcast guard', () => { await strategy.doPayout([order]); - expect(designateSpy).not.toHaveBeenCalled(); // re-entry: designateBeforeBroadcast is a no-op + expect(designateSpy).not.toHaveBeenCalled(); // manual speedup: designateBeforeBroadcast is a no-op expect(rollbackSpy).not.toHaveBeenCalled(); expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); expect(order.payoutTxId).toBe('OLD_TX'); 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 d0bc72bfc3..3458496e32 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 @@ -83,11 +83,17 @@ export abstract class EvmStrategy extends PayoutStrategy { this.logger.warn( `Payout order ${order.id} failed with out-of-gas (tx ${order.payoutTxId}), retrying with fresh nonce`, ); - if (!(await this.rollbackBroadcastForRetry(order, this.payoutOrderRepo))) continue; } else { - // TX expired (not on-chain, not in mempool) - retry immediately, no gas costs incurred - this.logger.info(`Payout order ${order.id} has expired TX (${order.payoutTxId}), retrying immediately`); + // Expired (vanished from the mempool): getTxNonce on the vanished hash resolves undefined, + // so a re-entry with payoutTxId set would broadcast an INDEPENDENT tx with a fresh nonce, + // outside the designate-before-broadcast protection. Roll back like the OOG path so the + // retry re-enters the regular protected flow (designation persisted before dispatch, + // ambiguous failures escalate instead of silently looping). + this.logger.warn( + `Payout order ${order.id} has expired TX (${order.payoutTxId}), retrying with fresh designation`, + ); } + if (!(await this.rollbackBroadcastForRetry(order, this.payoutOrderRepo))) continue; await this.doPayout([order]); } } catch (e) { 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 1576b2ce2f..9814017d39 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 @@ -81,7 +81,7 @@ export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { // The payout cron lock expires after 1800 seconds, so payout runs can overlap. An atomic // conditional transition prevents a stale run from broadcasting an order already claimed by // another run or overwriting its newer payout state; a plain entity save cannot enforce this. - // A re-entry with payoutTxId already set (EVM speedup/expired-retry) keeps its existing status. + // A re-entry with payoutTxId already set (EVM manual speedup) keeps its existing status. protected async designateBeforeBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { if (order.payoutTxId) return true; return this.claimForBroadcast(order, repo); @@ -93,15 +93,23 @@ export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { // and re-open the order for a second broadcast). protected async rollbackBroadcastForRetry(order: PayoutOrder, repo: PayoutOrderRepository): Promise { try { + // Append the released hash within the pinned update so the replaced tx stays reconstructable + // from the DB. slice keeps the newest entries; ~30 hashes fit, so overflow is pathological. + const releasedTxIds = [order.releasedPayoutTxIds, order.payoutTxId].filter((id) => id).join(';'); const result = await repo.update( { id: order.id, status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: order.payoutTxId }, - { status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }, + { + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + releasedPayoutTxIds: releasedTxIds.slice(-2048), + }, ); if (!result.affected) { this.designationLogger.warn(`Skipping payout retry for order ${order.id}: state changed concurrently`); return false; } + order.releasedPayoutTxIds = releasedTxIds.slice(-2048); order.rollbackPayout(); return true; } catch (e) { @@ -114,7 +122,7 @@ export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { // 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. + // break manual speedup 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) { From 933ddf95b5c4b4927a738fd66949cc88c227f2a4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:36:30 +0200 Subject: [PATCH 05/19] fix(payout): classify provably-pre-broadcast bitcoin-family send errors and cap their retries (#4238) * fix(payout): classify provably-pre-broadcast bitcoin-family send errors and cap their retries Since the fail-closed broadcast boundary, every send-RPC failure on the bitcoin-family path escalates to PayoutUncertain within 30s - including error classes that provably had no broadcast (connection-establishment failures, insufficient-funds/wallet-locked RPC errors, the Monero unlocked-balance race) and previously self-healed. At the same time the maxPreBroadcastRetries cap did not apply to this path at all, so a deterministic pre-broadcast failure could loop every 30s forever. Send-boundary errors are now classified: connection-establishment syscall failures and a narrow allowlist of deterministic pre-funding wallet RPC codes (cited from the official sources) stay plain errors and roll back for capped auto-retry; everything ambiguous keeps failing closed as TxBroadcastError. Zano stays fully fail-closed (codes not confirmable). The rollback path now respects the cap: orders over it keep PayoutDesignated and escalate. Closes #4230 * style: format tx-broadcast.error.spec.ts * fix(payout): classify parsed RPC errors independent of HTTP status, harden the cap partition Review follow-ups: bitcoind delivers in-band JSON-RPC errors of single requests over HTTP 500, so the blanket http-response override made the bitcoin/firo allowlist unreachable in production. The classifier now walks only client-attached links (cause/error, never raw response payloads): a parsed numeric RPC code classifies by the allowlist regardless of transport status, while bare transport errors carry no parsed code and stay fail-closed by construction. Client tests now model the real transport shape (rejected HTTP 500 with parsed error body). The cap partition routes a misconfigured (NaN) cap loudly into the fail-closed branch, Zano passes an explicit empty allowlist, and the merged empty-tx-id guards are preserved unchanged. * style: format classifier spec and monero/zano clients * fix: restore the dedicated empty-tx-hash guard on the zano transfer boundary * style: drop stray blank line in zano transfer guard * test(payout): cover the NaN cap partition and align the firo raw-broadcast guard Review follow-ups: the NaN-cap routing now has regression coverage (no rollback, loud warn for all orders), the duplicate weaker empty-hash tests from before the rebase are removed in favor of the stronger merged variants, the dead TxBroadcastError re-throw in the monero catch is gone, and the firo sendrawtransaction empty-txid guard throws TxBroadcastError directly like its sister guards (with a test). * style: format bitcoin-based strategy spec * fix(payout): qualify unreachable codes by connect phase and fail the classifier closed Second-reviewer follow-ups: EHOSTUNREACH/ENETUNREACH can surface as socket soft-errors on an ESTABLISHED connection (ICMP unreachable at the retransmission timeout) - only the connect-phase variant is provably pre-broadcast, so the classifier now requires syscall === 'connect' for these two codes. RPC_IN_WARMUP (-28, rejected by the dispatcher before execution) joins the bitcoin/firo allowlists so node restarts self-heal as the PR promises. The classifier itself now defaults closed if walking an exotic error shape throws, and the firo mint variable keeps its precise name. * test(payout): adjust bitcoin cap-test save counts for the conditional designation After the atomic designation merged to develop, designatePayout claims via repo.update instead of a full-entity save, so the pre-broadcast cap tests see one fewer save per order (failure tracking and rollback only). --- src/config/config.ts | 5 +- .../node/__tests__/bitcoin-client.spec.ts | 86 ++++++++++++++- .../bitcoin/node/bitcoin-based-client.ts | 32 ++++-- .../bitcoin/node/rpc/bitcoin-rpc-client.ts | 8 +- .../firo/__tests__/firo-client.spec.ts | 104 +++++++++++++++++- .../blockchain/firo/firo-client.ts | 33 ++++-- .../monero/__tests__/monero-client.spec.ts | 38 ++++++- .../blockchain/monero/monero-client.ts | 20 ++-- .../__tests__/tx-broadcast.error.spec.ts | 95 +++++++++++++++- .../shared/errors/tx-broadcast.error.ts | 76 +++++++++++++ .../zano/__test__/zano-client.spec.ts | 48 +++++++- .../blockchain/zano/zano-client.ts | 21 ++-- .../payout-bitcoin-based.strategy.spec.ts | 80 ++++++++++++++ .../impl/base/bitcoin-based.strategy.ts | 21 +++- 14 files changed, 602 insertions(+), 65 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index d3fa6d4559..4992d6d47d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -795,8 +795,9 @@ 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. + // (e.g. EVM gas estimation/nonce fetch or a bitcoin-family rollback-safe send failure). Beyond + // this, a permanently failing pre-broadcast step escalates to PAYOUT_UNCERTAIN instead of + // retrying forever. maxPreBroadcastRetries: +(process.env.PAYOUT_MAX_PRE_BROADCAST_RETRIES ?? 3), }; 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 28e358c647..02d0dc5bbf 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts @@ -205,13 +205,14 @@ describe('BitcoinClient', () => { expect(result.outTxId).toBe('newtxid123'); }); - it('should handle empty result gracefully', async () => { + it('should keep an empty result fail-closed', async () => { mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); - const result = await client.send('bc1qrecipient', 'inputtxid', 0.5, 0, 10); - - expect(result.outTxId).toBe(''); + await expect(client.send('bc1qrecipient', 'inputtxid', 0.5, 0, 10)).rejects.toMatchObject({ + name: 'TxBroadcastError', + message: 'Bitcoin broadcast returned an empty txid', + }); }); }); @@ -347,6 +348,83 @@ describe('BitcoinClient', () => { expect(error).toBeInstanceOf(TxBroadcastError); expect((error as TxBroadcastError).message).toBe('Bitcoin broadcast returned an empty txid'); }); + + it('should keep an allowlisted RPC code delivered over HTTP 500 plain', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); + mockRpcPost.mockImplementationOnce(() => + Promise.reject({ + response: { status: 500, data: { error: { code: -6, message: 'Insufficient funds' } } }, + message: 'Request failed with status code 500', + }), + ); + + await expect(client.sendMany(payload, 10)).rejects.not.toBeInstanceOf(TxBroadcastError); + }); + + it('should keep a non-allowlisted RPC code delivered over HTTP 500 fail-closed', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); + mockRpcPost.mockImplementationOnce(() => + Promise.reject({ + response: { status: 500, data: { error: { code: -4, message: 'Wallet error' } } }, + message: 'Request failed with status code 500', + }), + ); + + await expect(client.sendMany(payload, 10)).rejects.toBeInstanceOf(TxBroadcastError); + }); + + it('should keep a bare HTTP 500 without a parsed RPC error fail-closed', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); + mockRpcPost.mockImplementationOnce(() => + Promise.reject({ + response: { status: 500, data: 'Internal Server Error' }, + code: 'ERR_BAD_RESPONSE', + message: 'Request failed with status code 500', + }), + ); + + await expect(client.sendMany(payload, 10)).rejects.toBeInstanceOf(TxBroadcastError); + }); + + it('should keep an ECONNREFUSED connection failure plain', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + const connectionError = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }); + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); + mockRpcPost.mockImplementationOnce(() => Promise.reject(connectionError)); + + await expect(client.sendMany(payload, 10)).rejects.not.toBeInstanceOf(TxBroadcastError); + }); + + it('should keep a node warm-up (RPC_IN_WARMUP / -28) plain so restarts self-heal', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); + mockRpcPost.mockImplementationOnce(() => + Promise.reject({ + response: { status: 500, data: { error: { code: -28, message: 'Verifying blocks...' } } }, + message: 'Request failed with status code 500', + }), + ); + + await expect(client.sendMany(payload, 10)).rejects.not.toBeInstanceOf(TxBroadcastError); + }); + + it('should keep an ECONNABORTED timeout fail-closed', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + const timeoutError = Object.assign(new Error('timeout exceeded'), { code: 'ECONNABORTED' }); + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); + mockRpcPost.mockImplementationOnce(() => Promise.reject(timeoutError)); + + await expect(client.sendMany(payload, 10)).rejects.toBeInstanceOf(TxBroadcastError); + }); }); // --- 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 9a9f784902..a70c8e2bfc 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts @@ -3,10 +3,16 @@ 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 { TxBroadcastError, toBroadcastBoundaryError } from '../../shared/errors/tx-broadcast.error'; import { CoinOnly } from '../../shared/util/blockchain-client'; import { NodeClient, NodeClientConfig } from './node-client'; +const BITCOIN_PRE_BROADCAST_RPC_CODES = [ + -6, // RPC_WALLET_INSUFFICIENT_FUNDS (Bitcoin Core src/rpc/protocol.h) + -13, // RPC_WALLET_UNLOCK_NEEDED (Bitcoin Core src/rpc/protocol.h) + -28, // RPC_IN_WARMUP (Bitcoin Core src/rpc/protocol.h) — NodeNotReadyError carries this code; request never executes +]; + export interface TransactionHistory { address: string; category: string; @@ -49,9 +55,19 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly replaceable: true, }; - const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); + // Broadcast boundary: Bitcoin Core's `send` RPC builds, signs and broadcasts atomically. + // Connection-establishment failures and the protocol.h pre-funding codes stay plain; parsed + // RPC errors are deterministic even over HTTP 500, while ambiguous transport failures fail closed. + try { + const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); + if (!result?.txid) { + throw new TxBroadcastError('Bitcoin broadcast returned an empty txid', { cause: result }); + } - return { outTxId: result?.txid ?? '', feeAmount }; + return { outTxId: result.txid, feeAmount }; + } catch (e) { + throw toBroadcastBoundaryError(e, BITCOIN_PRE_BROADCAST_RPC_CODES); + } } async sendMany( @@ -70,10 +86,9 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly ...(subtractFeeFromOutputs && { subtract_fee_from_outputs: subtractFeeFromOutputs }), }; - // 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. + // Broadcast boundary: Bitcoin Core's `send` RPC builds, signs and broadcasts atomically. + // Connection-establishment failures and the protocol.h pre-funding codes stay plain; parsed + // RPC errors are deterministic even over HTTP 500, while ambiguous transport failures fail closed. // An empty/missing txid on a resolved response is equally ambiguous and must stay fail-closed // (not return '' which would later roll the payout order back for re-broadcast). try { @@ -83,8 +98,7 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly } return result.txid; } catch (e) { - if (e instanceof TxBroadcastError) throw e; - throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + throw toBroadcastBoundaryError(e, BITCOIN_PRE_BROADCAST_RPC_CODES); } } diff --git a/src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-client.ts b/src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-client.ts index cb6a9e6d9c..9a53a4a018 100644 --- a/src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-client.ts +++ b/src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-client.ts @@ -84,13 +84,17 @@ export class BitcoinRpcClient { if (rpcError) { if (rpcError.code === RPC_IN_WARMUP) throw new NodeNotReadyError(method, rpcError.message); - const error = new Error(`Bitcoin RPC ${method} failed: ${rpcError.message}`) as Error & { code: number }; + const error = new Error(`Bitcoin RPC ${method} failed: ${rpcError.message}`, { cause: e }) as Error & { + code: number; + }; error.code = rpcError.code; throw error; } // Re-throw with more context, preserving error code if present - const error = new Error(`Bitcoin RPC ${method} failed: ${axiosError.message ?? e}`) as Error & { code: number }; + const error = new Error(`Bitcoin RPC ${method} failed: ${axiosError.message ?? e}`, { cause: e }) as Error & { + code: number; + }; if (axiosError.code !== undefined) { error.code = axiosError.code; } else if ((e as Error & { code?: number }).code !== undefined) { diff --git a/src/integration/blockchain/firo/__tests__/firo-client.spec.ts b/src/integration/blockchain/firo/__tests__/firo-client.spec.ts index a40eef0436..29fec69a36 100644 --- a/src/integration/blockchain/firo/__tests__/firo-client.spec.ts +++ b/src/integration/blockchain/firo/__tests__/firo-client.spec.ts @@ -4,8 +4,8 @@ * 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. + * in one node-side call, like Bitcoin Core's `send`). At those boundaries only connection setup and + * allowlisted pre-funding errors remain plain; ambiguous failures stay TxBroadcastError. */ import { HttpService } from 'src/shared/services/http.service'; @@ -80,6 +80,25 @@ describe('FiroClient - broadcast boundary', () => { client = new FiroClient(mockHttpService, 'http://localhost:8000'); }); + function mockSendRawResponse(response: () => Promise): void { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'sendrawtransaction') return response(); + 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' }); + }); + } + describe('sendMany(...) -> buildSignAndBroadcast(...)', () => { it('returns the txid on a successful sendrawtransaction call', async () => { const result = await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); @@ -119,6 +138,81 @@ describe('FiroClient - broadcast boundary', () => { expect((error as TxBroadcastError).message).toBe('Bitcoin RPC sendrawtransaction failed: tx-fee-not-met'); }); + it.each([ + [-6, 'RPC_WALLET_INSUFFICIENT_FUNDS'], + [-13, 'RPC_WALLET_UNLOCK_NEEDED'], + ])('keeps allowlisted RPC code %i (%s) plain when delivered over HTTP 500', async (code) => { + mockSendRawResponse(() => + Promise.reject({ + response: { status: 500, data: { error: { code, message: 'deterministic wallet failure' } } }, + message: 'Request failed with status code 500', + }), + ); + + await expect(client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10)).rejects.not.toBeInstanceOf( + TxBroadcastError, + ); + }); + + it('keeps a non-allowlisted RPC code delivered over HTTP 500 fail-closed', async () => { + mockSendRawResponse(() => + Promise.reject({ + response: { status: 500, data: { error: { code: -4, message: 'Wallet error' } } }, + message: 'Request failed with status code 500', + }), + ); + + await expect(client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10)).rejects.toBeInstanceOf( + TxBroadcastError, + ); + }); + + it('keeps a bare HTTP 500 without a parsed RPC error fail-closed', async () => { + mockSendRawResponse(() => + Promise.reject({ + response: { status: 500, data: 'Internal Server Error' }, + code: 'ERR_BAD_RESPONSE', + message: 'Request failed with status code 500', + }), + ); + + await expect(client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10)).rejects.toBeInstanceOf( + TxBroadcastError, + ); + }); + + it('keeps an ECONNREFUSED sendrawtransaction failure plain', async () => { + const connectionError = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }); + mockSendRawResponse(() => Promise.reject(connectionError)); + + await expect(client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10)).rejects.not.toBeInstanceOf( + TxBroadcastError, + ); + }); + + it('keeps an ECONNABORTED timeout fail-closed', async () => { + const timeoutError = Object.assign(new Error('timeout exceeded'), { code: 'ECONNABORTED' }); + mockSendRawResponse(() => Promise.reject(timeoutError)); + + await expect(client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10)).rejects.toBeInstanceOf( + TxBroadcastError, + ); + }); + + it('keeps an RPC-successful-but-empty sendrawtransaction result fail-closed', async () => { + mockSendRawResponse(() => Promise.resolve({ result: '', 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('Firo sendrawtransaction returned no transaction ID'); + }); + it('does not wrap a pre-broadcast signing failure (plain Error propagates unchanged)', async () => { mockRpcPost.mockImplementation((_url, body) => { const parsed = JSON.parse(body); @@ -201,7 +295,7 @@ describe('FiroClient - broadcast boundary', () => { 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 () => { + it('keeps an RPC-successful-but-empty mintspark result fail-closed', async () => { mockRpcPost.mockImplementation((_url, body) => { const parsed = JSON.parse(body); if (parsed.method === 'mintspark') return Promise.resolve({ result: [], error: null, id: 'test' }); @@ -222,8 +316,8 @@ describe('FiroClient - broadcast boundary', () => { error = e; } - expect(error).not.toBeInstanceOf(TxBroadcastError); - expect((error as Error).message).toBe('mintspark returned no transaction IDs'); + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('Firo mintspark returned no transaction IDs'); }); it('does not wrap a pre-broadcast no-UTXO failure (plain Error propagates unchanged)', async () => { diff --git a/src/integration/blockchain/firo/firo-client.ts b/src/integration/blockchain/firo/firo-client.ts index dd3697d50d..6c2f6c5c6d 100644 --- a/src/integration/blockchain/firo/firo-client.ts +++ b/src/integration/blockchain/firo/firo-client.ts @@ -3,9 +3,15 @@ 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 { TxBroadcastError, toBroadcastBoundaryError } from '../shared/errors/tx-broadcast.error'; import { FiroRawTransaction } from './rpc'; +const FIRO_PRE_BROADCAST_RPC_CODES = [ + -6, // RPC_WALLET_INSUFFICIENT_FUNDS (Firo's Bitcoin-derived RPC protocol constants) + -13, // RPC_WALLET_UNLOCK_NEEDED (Firo's Bitcoin-derived RPC protocol constants) + -28, // RPC_IN_WARMUP (Bitcoin Core src/rpc/protocol.h) — NodeNotReadyError carries this code; request never executes +]; + /** * Firo RPC client - overrides Bitcoin Core methods that are incompatible with Firo. * @@ -190,12 +196,17 @@ export class FiroClient extends BitcoinBasedClient { throw new Error('Failed to sign Firo transaction'); } - // Broadcast boundary: sendrawtransaction is the actual network relay - everything above - // (createrawtransaction/signrawtransaction) is local/pre-broadcast and stays a plain Error. + // Broadcast boundary: sendrawtransaction is the actual network relay. Connection-establishment + // failures and the narrow wallet pre-funding allowlist stay plain; ambiguous failures fail closed. try { - return await this.callNode(() => this.rpc.call('sendrawtransaction', [signedResult.hex]), true); + const txId = await this.callNode(() => this.rpc.call('sendrawtransaction', [signedResult.hex]), true); + if (!txId) { + throw new TxBroadcastError('Firo sendrawtransaction returned no transaction ID', { cause: txId }); + } + + return txId; } catch (e) { - throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + throw toBroadcastBoundaryError(e, FIRO_PRE_BROADCAST_RPC_CODES); } } @@ -247,8 +258,8 @@ export class FiroClient extends BitcoinBasedClient { }; } - // 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. + // Broadcast boundary: mintspark builds, signs and broadcasts atomically in one node-side call. + // Only connection-establishment and allowlisted pre-funding failures are safe to retry. let mintTxIds: string[]; try { mintTxIds = await this.callNode( @@ -256,14 +267,12 @@ export class FiroClient extends BitcoinBasedClient { true, ); } catch (e) { - throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + throw toBroadcastBoundaryError(e, FIRO_PRE_BROADCAST_RPC_CODES); } - // 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). + // A missing txid is an ambiguous/malformed response and therefore remains fail-closed. if (!mintTxIds?.length) { - throw new Error('mintspark returned no transaction IDs'); + throw new TxBroadcastError('Firo mintspark returned no transaction IDs', { cause: mintTxIds }); } if (mintTxIds.length > 1) { diff --git a/src/integration/blockchain/monero/__tests__/monero-client.spec.ts b/src/integration/blockchain/monero/__tests__/monero-client.spec.ts index 485ccfb493..d092d0ed99 100644 --- a/src/integration/blockchain/monero/__tests__/monero-client.spec.ts +++ b/src/integration/blockchain/monero/__tests__/monero-client.spec.ts @@ -1,11 +1,9 @@ /** * 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). + * The wallet RPC's 'transfer' method builds, signs and relays a Monero transaction atomically. + * Connection-establishment failures and the two official pre-funding wallet codes remain plain; + * timeouts, resets, malformed responses and every other RPC code stay fail-closed. */ import { HttpService } from 'src/shared/services/http.service'; @@ -74,6 +72,30 @@ describe('MoneroClient - broadcast boundary', () => { expect((error as TxBroadcastError).cause).toBe(httpError); }); + it('keeps an ECONNREFUSED HTTP failure plain because the request never reached the wallet', async () => { + const connectionError = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }); + mockPost.mockRejectedValueOnce(connectionError); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBe(connectionError); + expect(error).not.toBeInstanceOf(TxBroadcastError); + }); + + it.each([ + [-17, 'WALLET_RPC_ERROR_CODE_NOT_ENOUGH_MONEY'], + [-37, 'WALLET_RPC_ERROR_CODE_NOT_ENOUGH_UNLOCKED_MONEY'], + ])('keeps allowlisted RPC code %i (%s) plain', async (code) => { + mockPost.mockResolvedValueOnce({ error: { code, message: 'deterministic pre-funding failure' } }); + + await expect(client.sendTransfers(payout)).rejects.not.toBeInstanceOf(TxBroadcastError); + }); + it('wraps a non-Error rejection into a TxBroadcastError via String(e)', async () => { mockPost.mockRejectedValueOnce('gateway timeout'); @@ -102,6 +124,12 @@ describe('MoneroClient - broadcast boundary', () => { expect((error as TxBroadcastError).message).toBe('Failed to send tx'); }); + it.each(['ECONNRESET', 'ETIMEDOUT'])('keeps an ambiguous %s HTTP failure fail-closed', async (code) => { + mockPost.mockRejectedValueOnce(Object.assign(new Error(code), { code })); + + await expect(client.sendTransfers(payout)).rejects.toBeInstanceOf(TxBroadcastError); + }); + it('wraps a response with neither result nor error into a TxBroadcastError', async () => { mockPost.mockResolvedValueOnce({}); diff --git a/src/integration/blockchain/monero/monero-client.ts b/src/integration/blockchain/monero/monero-client.ts index b158037b47..40339eab15 100644 --- a/src/integration/blockchain/monero/monero-client.ts +++ b/src/integration/blockchain/monero/monero-client.ts @@ -7,7 +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 { TxBroadcastError, toBroadcastBoundaryError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient, CoinOnly } from '../shared/util/blockchain-client'; import { AddressResultDto, @@ -25,6 +25,11 @@ import { } from './dto/monero.dto'; import { MoneroHelper } from './monero-helper'; +const MONERO_PRE_BROADCAST_RPC_CODES = [ + -17, // WALLET_RPC_ERROR_CODE_NOT_ENOUGH_MONEY (Monero src/wallet/wallet_rpc_server_error_codes.h) + -37, // WALLET_RPC_ERROR_CODE_NOT_ENOUGH_UNLOCKED_MONEY (Monero src/wallet/wallet_rpc_server_error_codes.h) +]; + export class MoneroClient extends BlockchainClient implements CoinOnly { constructor(private readonly http: HttpService) { super(); @@ -240,11 +245,9 @@ 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). + // Broadcast boundary: the wallet RPC's 'transfer' method builds, signs and relays atomically. + // Connection-establishment failures and the official wallet pre-funding codes stay plain; + // parsed RPC errors are deterministic, while ambiguous transport failures fail closed. async sendTransfers(payout: PayoutGroup): Promise { try { const result = await this.http.post( @@ -264,14 +267,13 @@ export class MoneroClient extends BlockchainClient implements CoinOnly { // 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 }); + throw toBroadcastBoundaryError(e, MONERO_PRE_BROADCAST_RPC_CODES); } } private mapSendTransfer(sendTransferResult: GetSendTransferResultDto): MoneroTransferDto { if (sendTransferResult.error) - throw new TxBroadcastError(sendTransferResult.error.message, { cause: sendTransferResult.error }); + throw toBroadcastBoundaryError(sendTransferResult.error, MONERO_PRE_BROADCAST_RPC_CODES); if (!sendTransferResult.result) throw new TxBroadcastError('No result after send transfer'); // Empty tx_hash after a resolved transfer is ambiguous (wallet may already have relayed). if (!sendTransferResult.result.tx_hash) { 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 index 4bb5ac45c4..3ce2712b3c 100644 --- a/src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts +++ b/src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts @@ -1,4 +1,4 @@ -import { TxBroadcastError } from '../tx-broadcast.error'; +import { TxBroadcastError, toBroadcastBoundaryError } from '../tx-broadcast.error'; describe('TxBroadcastError', () => { it('sets message and name', () => { @@ -18,3 +18,96 @@ describe('TxBroadcastError', () => { expect(error.cause).toBe(cause); }); }); + +describe('toBroadcastBoundaryError', () => { + it.each(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN'])( + 'returns the original Error for unconditional pre-broadcast syscall code %s', + (code) => { + const error = Object.assign(new Error(code), { code }); + + expect(toBroadcastBoundaryError(error, [])).toBe(error); + }, + ); + + it('keeps EHOSTUNREACH with syscall connect plain (connect-phase only)', () => { + const error = Object.assign(new Error('connect EHOSTUNREACH'), { + code: 'EHOSTUNREACH', + syscall: 'connect', + }); + + expect(toBroadcastBoundaryError(error, [])).toBe(error); + }); + + it('keeps EHOSTUNREACH with syscall read fail-closed (soft-error after possible delivery)', () => { + const error = Object.assign(new Error('read EHOSTUNREACH'), { + code: 'EHOSTUNREACH', + syscall: 'read', + }); + + expect(toBroadcastBoundaryError(error, [])).toBeInstanceOf(TxBroadcastError); + }); + + it('finds a pre-broadcast syscall code through an Error cause', () => { + const connectionError = Object.assign(new Error('connect failed'), { code: 'ECONNREFUSED' }); + const outerError = new Error('request failed', { cause: connectionError }); + + expect(toBroadcastBoundaryError(outerError, [])).toBe(outerError); + }); + + it('keeps a numeric RPC code plain only when the client allowlists it', () => { + const rpcError = Object.assign(new Error('insufficient funds'), { code: -6 }); + + expect(toBroadcastBoundaryError(rpcError, [-6])).toBe(rpcError); + expect(toBroadcastBoundaryError(rpcError, [])).toBeInstanceOf(TxBroadcastError); + }); + + it('keeps an allowlisted parsed RPC error plain when Bitcoin Core delivered it over HTTP 500', () => { + const axiosError = Object.assign(new Error('Request failed with status code 500'), { + response: { status: 500, data: { error: { code: -6, message: 'insufficient funds' } } }, + }); + const parsedRpcError = Object.assign( + new Error('Bitcoin RPC send failed: insufficient funds', { cause: axiosError }), + { + code: -6, + }, + ); + + expect(toBroadcastBoundaryError(parsedRpcError, [-6])).toBe(parsedRpcError); + }); + + it('does not classify an RPC-looking code found only in a raw transport response', () => { + const axiosError = Object.assign(new Error('Request failed with status code 500'), { + response: { status: 500, data: { error: { code: -6, message: 'insufficient funds' } } }, + }); + + expect(toBroadcastBoundaryError(axiosError, [-6])).toBeInstanceOf(TxBroadcastError); + }); + + it('handles a cyclic cause chain without recursing forever', () => { + const cyclicError = new Error('cyclic transport error') as Error & { cause?: unknown }; + cyclicError.cause = cyclicError; + + expect(toBroadcastBoundaryError(cyclicError, [])).toBeInstanceOf(TxBroadcastError); + }); + + it.each(['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED'])('keeps ambiguous transport code %s fail-closed', (code) => { + const error = Object.assign(new Error(code), { code }); + + expect(toBroadcastBoundaryError(error, [])).toBeInstanceOf(TxBroadcastError); + }); + + it('keeps a throwing-getter error fail-closed (classifier defaults closed on its own failures)', () => { + const error = {}; + Object.defineProperty(error, 'code', { + get() { + throw new Error('getter boom'); + }, + }); + + const result = toBroadcastBoundaryError(error, []); + + expect(result).toBeInstanceOf(TxBroadcastError); + expect(result.message).toBe('Unclassifiable send error'); + expect(result.cause).toBe(error); + }); +}); diff --git a/src/integration/blockchain/shared/errors/tx-broadcast.error.ts b/src/integration/blockchain/shared/errors/tx-broadcast.error.ts index 292a4968e7..a4c16fb4c9 100644 --- a/src/integration/blockchain/shared/errors/tx-broadcast.error.ts +++ b/src/integration/blockchain/shared/errors/tx-broadcast.error.ts @@ -7,3 +7,79 @@ export class TxBroadcastError extends Error { this.name = 'TxBroadcastError'; } } + +// Connection-establishment / name-resolution failures that only occur before the request is sent. +const PRE_BROADCAST_CONNECT_CODES = ['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']; + +// Same codes can surface after the request may already have been delivered: on Linux, ICMP +// unreachable on an ESTABLISHED connection is stored as a socket soft-error and surfaces at the +// retransmission timeout on read/write with EHOSTUNREACH/ENETUNREACH. Only the connect-phase +// variant (syscall === 'connect' on the same error object) is provably pre-broadcast. +const PRE_BROADCAST_CONNECT_ONLY_UNREACHABLE_CODES = ['EHOSTUNREACH', 'ENETUNREACH']; + +type ErrorShape = { + cause?: unknown; + code?: unknown; + error?: unknown; + message?: unknown; + syscall?: unknown; +}; + +// Send-boundary classification is deliberately fail-closed: +// - Class A: connection-establishment failures are plain errors because the request never reached the node. +// - Class B: only parsed numeric RPC codes that prove funding failed before tx creation are plain. Bitcoin Core +// delivers JSON-RPC errors over HTTP 500, but a client-parsed error body is still a deterministic node answer. +// - Class C: bare transport errors carry no parsed numeric RPC code; timeouts, resets and every unknown/ambiguous +// shape therefore stay TxBroadcastError. +export function toBroadcastBoundaryError(e: unknown, preBroadcastRpcCodes: number[]): Error { + if (e instanceof TxBroadcastError) return e; + + // A soundness classifier defaults closed: if walking/normalizing the error itself throws + // (throwing getters, null-prototype objects breaking String(value)), do not let the classifier's + // own failure escape as a plain (retryable) error. + try { + const isPreBroadcastSyscall = walkErrorShape(e, (value) => { + if (typeof value.code !== 'string') return false; + if (PRE_BROADCAST_CONNECT_CODES.includes(value.code)) return true; + // ICMP unreachable on an established connection surfaces at the retransmission timeout with the + // same code (syscall 'read'/'write') — only the connect-phase variant is provably pre-broadcast. + if (PRE_BROADCAST_CONNECT_ONLY_UNREACHABLE_CODES.includes(value.code)) { + return value.syscall === 'connect'; + } + return false; + }); + const isPreBroadcastRpcError = walkErrorShape( + e, + (value) => typeof value.code === 'number' && preBroadcastRpcCodes.includes(value.code), + ); + + if (isPreBroadcastSyscall || isPreBroadcastRpcError) return asError(e); + + const error = asError(e); + return new TxBroadcastError(error.message, { cause: e }); + } catch { + return new TxBroadcastError('Unclassifiable send error', { cause: e }); + } +} + +function walkErrorShape(value: unknown, matches: (value: ErrorShape) => boolean, seen = new Set()): boolean { + if (!isErrorShape(value) || seen.has(value)) return false; + + seen.add(value); + if (matches(value)) return true; + + // Only follow links explicitly attached by a client/RPC layer or an in-band error object. Raw + // response/data payloads are transport details and must never introduce a classifiable RPC code. + return [value.cause, value.error].some((nested) => walkErrorShape(nested, matches, seen)); +} + +function isErrorShape(value: unknown): value is ErrorShape { + return typeof value === 'object' && value !== null; +} + +function asError(value: unknown): Error { + if (value instanceof Error) return value; + + const message = isErrorShape(value) && typeof value.message === 'string' ? value.message : String(value); + return new Error(message, { cause: value }); +} diff --git a/src/integration/blockchain/zano/__test__/zano-client.spec.ts b/src/integration/blockchain/zano/__test__/zano-client.spec.ts index 2b8848afcb..ea5a082e69 100644 --- a/src/integration/blockchain/zano/__test__/zano-client.spec.ts +++ b/src/integration/blockchain/zano/__test__/zano-client.spec.ts @@ -1,12 +1,9 @@ /** * 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). + * The wallet RPC's 'transfer' method builds, signs and relays a Zano transaction atomically. + * Connection-establishment failures remain plain. Without verified numeric pre-funding constants, + * every coded RPC error, timeout, reset and malformed response stays fail-closed. */ import { HttpService } from 'src/shared/services/http.service'; @@ -81,6 +78,45 @@ describe('ZanoClient - broadcast boundary', () => { expect((error as TxBroadcastError).cause).toBe(httpError); }); + it('keeps an ECONNREFUSED transfer failure plain because the request never reached the wallet', async () => { + const connectionError = Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }); + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.reject(connectionError); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBe(connectionError); + expect(error).not.toBeInstanceOf(TxBroadcastError); + }); + + it('keeps an in-band RPC error fail-closed because Zano has no verified numeric allowlist', async () => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.resolve({ error: { code: -17, message: 'not enough money' } }); + }); + + await expect(client.sendCoins(payout)).rejects.toBeInstanceOf(TxBroadcastError); + }); + + it.each(['ECONNRESET', 'ETIMEDOUT'])('keeps an ambiguous %s transfer failure fail-closed', async (code) => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.reject(Object.assign(new Error(code), { code })); + }); + + await expect(client.sendCoins(payout)).rejects.toBeInstanceOf(TxBroadcastError); + }); + 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') diff --git a/src/integration/blockchain/zano/zano-client.ts b/src/integration/blockchain/zano/zano-client.ts index 365c549afc..109a6667a4 100644 --- a/src/integration/blockchain/zano/zano-client.ts +++ b/src/integration/blockchain/zano/zano-client.ts @@ -7,7 +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 { TxBroadcastError, toBroadcastBoundaryError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient, BlockchainToken } from '../shared/util/blockchain-client'; import { ZanoAddressDto, @@ -24,6 +24,10 @@ import { } from './dto/zano.dto'; import { ZanoHelper } from './zano-helper'; +// Numeric pre-broadcast codes could not be confirmed from an authoritative Zano source, so every +// numeric RPC error is deliberately fail-closed. +const ZANO_PRE_BROADCAST_RPC_CODES: number[] = []; + export class ZanoClient extends BlockchainClient { private readonly tokens = new AsyncCache(); @@ -252,11 +256,9 @@ 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). + // Broadcast boundary: the wallet RPC's 'transfer' method builds, signs and relays atomically. + // Connection-establishment failures are provably pre-broadcast. Zano has no verified numeric + // pre-funding allowlist here, so RPC errors, timeouts, resets and malformed responses fail closed. private async doSendTransfer( payout: PayoutGroup, payoutAmount: number, @@ -279,19 +281,20 @@ export class ZanoClient extends BlockchainClient { try { const response = await this.http.post<{ - result: { tx_details: { tx_hash: string } }; + result?: { tx_details: { tx_hash: string } }; + error?: { code: number; message: string }; }>(`${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 }); + throw toBroadcastBoundaryError(e, ZANO_PRE_BROADCAST_RPC_CODES); } } private createSendTransferResult(payoutAmount: number, response?: any): ZanoSendTransferResultDto { + if (response?.error) throw toBroadcastBoundaryError(response.error, ZANO_PRE_BROADCAST_RPC_CODES); if (!response.result?.tx_details) throw new TxBroadcastError(`Transfer not sent: response was ${JSON.stringify(response)}`); // Empty tx_hash after a resolved transfer is ambiguous (wallet may already have relayed). 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 875c27c684..539f915e7a 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 @@ -34,6 +34,9 @@ describe('PayoutBitcoinBasedStrategy', () => { let sendErrorMailSpy: jest.SpyInstance; beforeEach(() => { + new ConfigService(); + Config.payout.maxPreBroadcastRetries = 3; + notificationService = mock(); payoutOrderRepo = mock(); bitcoinService = mock(); @@ -433,6 +436,83 @@ describe('PayoutBitcoinBasedStrategy', () => { expect(repoSaveSpy).not.toHaveBeenCalledWith(loser); }); + it('rolls back and saves a pre-broadcast failure at the configured retry cap', async () => { + const order = createCustomPayoutOrder({ + id: 50, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + retryCount: Config.payout.maxPreBroadcastRetries - 1, + }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('deterministic pre-broadcast failure')); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(order.retryCount).toBe(Config.payout.maxPreBroadcastRetries); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); // failure tracking, rollback (designation is a conditional update) + }); + + it('does not roll back above the pre-broadcast retry cap and warns before escalation', async () => { + const order = createCustomPayoutOrder({ + id: 51, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + retryCount: Config.payout.maxPreBroadcastRetries, + }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + const loggerWarnSpy = jest.spyOn((strategy as any).logger, 'warn'); + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('deterministic pre-broadcast failure')); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(order.retryCount).toBe(Config.payout.maxPreBroadcastRetries + 1); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // failure tracking only (designation is a conditional update) + expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('retry cap 3 exceeded for order(s) 51')); + }); + + // A misconfigured NaN cap makes every `retryCount <= cap` comparison false, so the negated + // partition routes all orders into the fail-closed branch (no silent self-heal under bad config). + it('does not roll back when the retry cap is NaN and warns for all orders', async () => { + Config.payout.maxPreBroadcastRetries = NaN; + const orders = [ + createCustomPayoutOrder({ id: 60, status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }), + createCustomPayoutOrder({ id: 61, status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }), + ]; + const rollbackSpies = orders.map((order) => jest.spyOn(order, 'rollbackPayoutDesignation')); + const loggerWarnSpy = jest.spyOn((strategy as any).logger, 'warn'); + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('deterministic pre-broadcast failure')); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(orders.every((order) => order.status === PayoutOrderStatus.PAYOUT_DESIGNATED)).toBe(true); + for (const spy of rollbackSpies) expect(spy).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); // failure tracking only, one save each (designation is a conditional update) + expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('retry cap NaN exceeded for order(s) 60, 61')); + }); + + it('still fires the recurring-failure alert at its threshold when the retry cap is configured above it', async () => { + Config.payout.maxPreBroadcastRetries = 6; + const order = createCustomPayoutOrder({ + id: 52, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + retryCount: 4, + }); + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('deterministic pre-broadcast failure')); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(order.retryCount).toBe(5); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(sendErrorMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ correlationId: expect.stringContaining('PayoutOrderRecurringFailure') }), + ); + }); + // 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". 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 da9ddc5291..513ffe1e35 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 @@ -163,7 +163,26 @@ export abstract class BitcoinBasedStrategy extends PayoutStrategy { // pre-broadcast and safe to roll back for auto-retry. if (e instanceof PayoutBroadcastException) throw e; - await this.rollbackPayoutDesignation(designated); + // trackPayoutFailure increments before this check, hence <= mirrors handleBroadcastError's + // pre-increment < cap check. With the default cap (3) below the recurring-alert threshold + // (5), processFailedOrders escalation supersedes that alert; when configured above 5, the + // recurring alert still fires before an order eventually exceeds this cap. Partition the + // claim-owned `designated` subset (not the full input) so a claim-race loser is never touched. + const cap = Config.payout.maxPreBroadcastRetries; + const retryableOrders = designated.filter((order) => order.retryCount <= cap); + // The negated predicate deliberately routes a misconfigured NaN cap into the fail-closed + // branch so the warning remains loud and no order silently falls out of the partition. + const cappedOrders = designated.filter((order) => !(order.retryCount <= cap)); + + if (cappedOrders.length) { + this.logger.warn( + `Pre-broadcast payout retry cap ${cap} exceeded for order(s) ${cappedOrders + .map((order) => order.id) + .join(', ')}; keeping PAYOUT_DESIGNATED for escalation`, + ); + } + + await this.rollbackPayoutDesignation(retryableOrders); return; } From 7b3ad3892143dfc33c0a43d18d046423a58342b0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:36:47 +0200 Subject: [PATCH 06/19] feat(payout): add a verification-gated admin retry for uncertain payout orders (#4240) * feat(payout): add a verification-gated admin retry for uncertain payout orders PayoutUncertain is the intended fail-closed outcome of every ambiguous broadcast failure, but there was no API path to bring an investigated order (verified: no transaction was ever broadcast) back into the payout flow - the only option was a direct DB status reset. POST /payout/retry (admin) accepts an order in PayoutUncertain without a payoutTxId, requires an explicit confirmation flag plus a verification reference, and resets the order via an atomic conditional transition to PreparationConfirmed - so it re-enters the regular cron flow under the full designate-before-broadcast protection instead of calling doPayout directly. The action is logged with actor, before-state and reference; the order's failure history is deliberately kept until the next successful broadcast. Closes #4231 * style: format payout service spec * fix(payout): restore the pre-broadcast retry budget on manual retry An order that escalated via the retry cap still carries retryCount at the cap; without a reset, the first transient pre-broadcast error of the manual retry skips both rollback and failure recording and silently re-escalates to PayoutUncertain within one cron cycle - with the escalation mail typically suppressed as recurring. The conditional transition now resets retryCount; lastError/lastAttemptDate stay as history and the audit log records the old count. --- .../supporting/payout/dto/retry-payout.dto.ts | 21 ++++ .../supporting/payout/payout.controller.ts | 11 ++ .../services/__tests__/payout.service.spec.ts | 114 +++++++++++++++++- .../payout/services/payout.service.ts | 39 +++++- 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 src/subdomains/supporting/payout/dto/retry-payout.dto.ts diff --git a/src/subdomains/supporting/payout/dto/retry-payout.dto.ts b/src/subdomains/supporting/payout/dto/retry-payout.dto.ts new file mode 100644 index 0000000000..573160fd6f --- /dev/null +++ b/src/subdomains/supporting/payout/dto/retry-payout.dto.ts @@ -0,0 +1,21 @@ +import { Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class RetryPayoutDto { + @IsNotEmpty() + @IsInt() + @Type(() => Number) + id: number; + + // Explicit operator confirmation that on-chain absence was verified — the endpoint must + // refuse anything but literal true. + @IsNotEmpty() + @IsBoolean() + noBroadcastVerified: boolean; + + // What was checked (explorer links, ticket, tooling reference) — becomes part of the audit log. + @IsNotEmpty() + @IsString() + @MaxLength(1024) + verificationReference: string; +} diff --git a/src/subdomains/supporting/payout/payout.controller.ts b/src/subdomains/supporting/payout/payout.controller.ts index 4441ecc315..5acccd5e54 100644 --- a/src/subdomains/supporting/payout/payout.controller.ts +++ b/src/subdomains/supporting/payout/payout.controller.ts @@ -2,9 +2,12 @@ import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; import { Config, Environment } from 'src/config/config'; +import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { RetryPayoutDto } from './dto/retry-payout.dto'; import { PayoutOrderContext } from './entities/payout-order.entity'; import { PayoutRequest } from './interfaces'; import { PayoutService } from './services/payout.service'; @@ -44,4 +47,12 @@ export class PayoutController { async speedupTransaction(@Query('id') id: string): Promise { return this.payoutService.speedupTransaction(+id); } + + @Post('retry') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async retryUncertainPayout(@GetJwt() jwt: JwtPayload, @Body() dto: RetryPayoutDto): Promise { + return this.payoutService.retryUncertainPayout(jwt.account, dto); + } } diff --git a/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts index 2cfcb0589f..d1a27439f5 100644 --- a/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts +++ b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts @@ -1,10 +1,11 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, 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 { Util } from 'src/shared/utils/util'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { In, LessThan, MoreThan } from 'typeorm'; +import { RetryPayoutDto } from '../../dto/retry-payout.dto'; 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'; @@ -17,6 +18,117 @@ import { PayoutLogService } from '../payout-log.service'; import { PayoutService } from '../payout.service'; describe('PayoutService', () => { + describe('#retryUncertainPayout(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + + const accountId = 42; + const baseDto: RetryPayoutDto = { + id: 1, + noBroadcastVerified: true, + verificationReference: 'explorer: no tx; ticket SUP-123', + }; + + beforeEach(() => { + payoutOrderRepo = mock(); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('throws NotFoundException when the order does not exist', async () => { + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(null); + const updateSpy = jest.spyOn(payoutOrderRepo, 'update'); + + await expect(service.retryUncertainPayout(accountId, baseDto)).rejects.toThrow(NotFoundException); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when status is not PAYOUT_UNCERTAIN', async () => { + const order = createCustomPayoutOrder({ + id: 1, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: undefined, + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + const updateSpy = jest.spyOn(payoutOrderRepo, 'update'); + + await expect(service.retryUncertainPayout(accountId, baseDto)).rejects.toThrow(BadRequestException); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when payoutTxId is set (must be reconciled, not retried)', async () => { + const order = createCustomPayoutOrder({ + id: 1, + status: PayoutOrderStatus.PAYOUT_UNCERTAIN, + payoutTxId: 'PTX_ALREADY_SET', + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + const updateSpy = jest.spyOn(payoutOrderRepo, 'update'); + + await expect(service.retryUncertainPayout(accountId, baseDto)).rejects.toThrow(BadRequestException); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when noBroadcastVerified is false and never updates', async () => { + const order = createCustomPayoutOrder({ + id: 1, + status: PayoutOrderStatus.PAYOUT_UNCERTAIN, + payoutTxId: undefined, + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + const updateSpy = jest.spyOn(payoutOrderRepo, 'update'); + + await expect(service.retryUncertainPayout(accountId, { ...baseDto, noBroadcastVerified: false })).rejects.toThrow( + BadRequestException, + ); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('resets status to PREPARATION_CONFIRMED with a conditional update on PAYOUT_UNCERTAIN', async () => { + const order = createCustomPayoutOrder({ + id: 1, + status: PayoutOrderStatus.PAYOUT_UNCERTAIN, + payoutTxId: undefined, + retryCount: 3, + }); + order.lastError = 'broadcast ambiguous'; + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + const updateSpy = jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 1 } as any); + const infoSpy = jest.spyOn(service['logger'], 'info'); + + await service.retryUncertainPayout(accountId, baseDto); + + expect(updateSpy).toHaveBeenCalledWith( + { id: order.id, status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + { status: PayoutOrderStatus.PREPARATION_CONFIRMED, retryCount: 0 }, + ); + expect(infoSpy).toHaveBeenCalled(); + }); + + it('throws ConflictException when the conditional update affects no rows (concurrent state change)', async () => { + const order = createCustomPayoutOrder({ + id: 1, + status: PayoutOrderStatus.PAYOUT_UNCERTAIN, + payoutTxId: undefined, + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + jest.spyOn(payoutOrderRepo, 'update').mockResolvedValue({ affected: 0 } as any); + + await expect(service.retryUncertainPayout(accountId, baseDto)).rejects.toThrow(ConflictException); + }); + }); + describe('#speedupTransaction(...)', () => { let service: PayoutService; let payoutOrderRepo: PayoutOrderRepository; diff --git a/src/subdomains/supporting/payout/services/payout.service.ts b/src/subdomains/supporting/payout/services/payout.service.ts index a0af6f44d3..920e2d06bf 100644 --- a/src/subdomains/supporting/payout/services/payout.service.ts +++ b/src/subdomains/supporting/payout/services/payout.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -9,6 +9,7 @@ import { MailContext, MailType } from 'src/subdomains/supporting/notification/en import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { FindOptionsRelations, In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; import { MailRequest } from '../../notification/interfaces'; +import { RetryPayoutDto } from '../dto/retry-payout.dto'; import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../entities/payout-order.entity'; import { PayoutOrderFactory } from '../factories/payout-order.factory'; import { FeeResult, PayoutRequest } from '../interfaces'; @@ -129,6 +130,42 @@ export class PayoutService { await strategy.doPayout([order]); } + async retryUncertainPayout(accountId: number, dto: RetryPayoutDto): Promise { + const order = await this.payoutOrderRepo.findOneBy({ id: dto.id }); + if (!order) throw new NotFoundException('Payout order not found'); + + if (order.status !== PayoutOrderStatus.PAYOUT_UNCERTAIN) + throw new BadRequestException( + `Payout order ${dto.id} cannot be retried in status ${order.status}, expected ${PayoutOrderStatus.PAYOUT_UNCERTAIN}`, + ); + + // An order with a payoutTxId needs reconciliation against the chain, not a retry. + if (order.payoutTxId) + throw new BadRequestException( + `Payout order ${dto.id} has payoutTxId ${order.payoutTxId} and must be reconciled, not retried`, + ); + + if (dto.noBroadcastVerified !== true) + throw new BadRequestException('On-chain absence must be verified and confirmed (noBroadcastVerified)'); + + // Atomic conditional transition — a concurrent state change must not be overwritten. + // Restore the pre-broadcast retry budget: orders that escalated via the cap still carry + // retryCount = maxPreBroadcastRetries; without this reset the first transient pre-broadcast + // error on the manual retry would silently re-escalate to PayoutUncertain. + const result = await this.payoutOrderRepo.update( + { id: order.id, status: PayoutOrderStatus.PAYOUT_UNCERTAIN }, + { status: PayoutOrderStatus.PREPARATION_CONFIRMED, retryCount: 0 }, + ); + if (!result.affected) throw new ConflictException(`Payout order ${dto.id} changed state concurrently, not retried`); + + // Audit trail (before → after): failure MESSAGE history (lastError/lastAttemptDate) is kept + // until the next broadcast attempt overwrites it; the retry BUDGET is restored above so a + // verified manual retry tolerates transient pre-broadcast errors instead of re-escalating. + this.logger.info( + `Manual payout retry authorized for order ${dto.id} by account ${accountId}: status ${PayoutOrderStatus.PAYOUT_UNCERTAIN} -> ${PayoutOrderStatus.PREPARATION_CONFIRMED}, retryCount ${order.retryCount}, lastError '${order.lastError}', reference: ${dto.verificationReference}`, + ); + } + //*** JOBS ***// @DfxCron(CronExpression.EVERY_30_SECONDS, { process: Process.PAY_OUT, timeout: 1800 }) async processOrders(): Promise { From f938975f4454b8f696f54cb50801bf92b95322cc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:37:07 +0200 Subject: [PATCH 07/19] fix(dex): persist purchase orders before the swap and guard in-flight duplicates (#4248) * fix(dex): persist purchase orders before the swap and guard in-flight duplicates The liquidity purchase dispatched the swap before the order was ever saved: a crash in that window left no row at all, so the upstream caller re-issued the purchase and swapped a second time - and nothing prevented duplicate purchase orders, the (context, correlationId) index is non-unique. The order row is now persisted first as the in-flight marker, and a partial unique index (isComplete = false AND type = 'Purchase') rejects a concurrent duplicate purchase at INSERT, before anything reaches the chain (scoped so reservations and historical rows stay out). EvmClient.doSwap gets an exact broadcast boundary: only send failures wrap as TxBroadcastError and keep the row in-flight; provable pre-broadcast errors cancel the row and preserve the existing retry semantics. The tx id is persisted immediately after dispatch, and stranded in-flight purchases alert after 15 minutes (no auto-cancel). Part 2 of #4192 * test: wire the id through the liquidity-order mock factory * fix(dex): keep estimation reverts pre-broadcast and make correlation readers deterministic Review follow-ups: doSwap now estimates the gas limit explicitly before the broadcast boundary - estimation reverts (including the routine slippage revert) stay plain pre-broadcast errors, preserving the slippage handling and the retry self-healing. The single-row correlation readers return the newest row so a persisted cancelled attempt cannot shadow the live retry row. The stranded-purchase alert now covers rows with a dispatched-but-never-completed txId as well (two labeled lists, no eager loads), the entity comment no longer misstates TypeORM's partial-index capability, and the doSwap boundary has dedicated tests. * style: object select syntax and typed error list in the stranded-purchase alert * fix(dex): match the slippage revert reason chain and exclude ready orders from stranded alerts Review follow-up: moving the gas estimation before the broadcast boundary changed the ethers error shape - a direct estimateGas revert surfaces the reason at e.reason, while the previous send-time estimation nested it at e.error.reason. The slippage check now walks the whole reason chain so PriceSlippageException is still raised and the retry self-heals. The stranded-purchase alert additionally requires isReady = false, so healthy but slow in-flight buys (ready, awaiting batch completion) no longer produce false operator alarms. Adds a dex-evm.service slippage-mapping spec and uses the realistic ethers error shape in the doSwap test. * style: format dex-evm service spec --- ...quidityOrderInflightPurchaseUniqueIndex.js | 32 +++++ .../shared/evm/__tests__/evm-client.spec.ts | 90 +++++++++++-- .../blockchain/shared/evm/evm-client.ts | 24 +++- .../__mocks__/liquidity-order.entity.mock.ts | 2 + .../dex/entities/liquidity-order.entity.ts | 2 + .../services/__tests__/dex.service.spec.ts | 114 ++++++++++++++++ .../base/__tests__/dex-evm.service.spec.ts | 66 +++++++++ .../dex/services/base/dex-evm.service.ts | 6 +- .../supporting/dex/services/dex.service.ts | 75 ++++++++++- .../__tests__/purchase.strategy.spec.ts | 125 ++++++++++++++++++ .../impl/base/purchase.strategy.ts | 36 ++++- 11 files changed, 549 insertions(+), 23 deletions(-) create mode 100644 migration/1784160000000-AddLiquidityOrderInflightPurchaseUniqueIndex.js create mode 100644 src/subdomains/supporting/dex/services/__tests__/dex.service.spec.ts create mode 100644 src/subdomains/supporting/dex/services/base/__tests__/dex-evm.service.spec.ts create mode 100644 src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase.strategy.spec.ts diff --git a/migration/1784160000000-AddLiquidityOrderInflightPurchaseUniqueIndex.js b/migration/1784160000000-AddLiquidityOrderInflightPurchaseUniqueIndex.js new file mode 100644 index 0000000000..0a83ceecde --- /dev/null +++ b/migration/1784160000000-AddLiquidityOrderInflightPurchaseUniqueIndex.js @@ -0,0 +1,32 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Prevents a second in-flight purchase from being created for the same (context, correlationId) + * before the first purchase has a definitive outcome. Completed and cancelled purchase rows are + * intentionally excluded, as are reservations that may legitimately share the correlation ID. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddLiquidityOrderInflightPurchaseUniqueIndex1784160000000 { + name = 'AddLiquidityOrderInflightPurchaseUniqueIndex1784160000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_liquidity_order_inflight_purchase" ON "liquidity_order" ("context", "correlationId") WHERE "isComplete" = false AND "type" = 'Purchase'`, + ); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_liquidity_order_inflight_purchase"`); + } +}; diff --git a/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts index bfb1b5695d..641468794d 100644 --- a/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts +++ b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts @@ -1,20 +1,18 @@ /** - * 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` + * Focused unit tests for the EvmClient broadcast boundary: the try/catch blocks around the actual + * on-chain send calls in `sendNativeCoin` and `doSwap` (wallet.sendTransaction) and `sendToken` * (contract.transfer), which translate any failure at/after the broadcast call into a - * TxBroadcastError. + * TxBroadcastError while leaving explicit pre-broadcast gas estimation failures unchanged. * * 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 + * real instance would need live network config. To isolate JUST the 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. + * EvmClient's prototype chain) with only the handful of collaborators the methods under test + * depend on 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'; @@ -23,6 +21,9 @@ import { EvmClient } from '../evm-client'; const proto = EvmClient.prototype as any; interface ClientStub { + provider: { estimateGas: jest.Mock }; + wallet: { address: string; sendTransaction: jest.Mock }; + swapContractAddress: string; getCurrentGasForCoinTransaction: jest.Mock; getTokenGasLimitForContact: jest.Mock; getRecommendedGasPrice: jest.Mock; @@ -34,6 +35,9 @@ interface ClientStub { function createClientStub(): ClientStub { const client = Object.create(EvmClient.prototype) as ClientStub; + client.provider = { estimateGas: jest.fn().mockResolvedValue(120000) }; + client.wallet = { address: 'FROM_ADDR', sendTransaction: jest.fn() }; + client.swapContractAddress = 'SWAP_CONTRACT_ADDR'; client.getCurrentGasForCoinTransaction = jest.fn().mockResolvedValue(21000); client.getTokenGasLimitForContact = jest.fn().mockResolvedValue(65000); client.getRecommendedGasPrice = jest.fn().mockResolvedValue(1_000_000_000); @@ -212,4 +216,72 @@ describe('EvmClient - broadcast boundary', () => { expect(client.setNonce).not.toHaveBeenCalled(); // never advance the nonce on an unconfirmed broadcast }); }); + + describe('doSwap(...)', () => { + const parameters = { calldata: '0x1234', value: '100' }; + + it('wraps a wallet.sendTransaction failure in a TxBroadcastError', async () => { + const client = createClientStub(); + const rpcError = new Error('replacement transaction underpriced'); + client.wallet.sendTransaction.mockRejectedValue(rpcError); + + let error: unknown; + try { + await proto.doSwap.call(client, parameters); + } 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.provider.estimateGas).toHaveBeenCalledWith({ + data: '0x1234', + to: 'SWAP_CONTRACT_ADDR', + value: '100', + from: 'FROM_ADDR', + gasPrice: 1_000_000_000, + nonce: 5, + }); + expect(client.wallet.sendTransaction).toHaveBeenCalledWith(expect.objectContaining({ gasLimit: 120000 })); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('wraps an empty transaction hash in a TxBroadcastError', async () => { + const client = createClientStub(); + client.wallet.sendTransaction.mockResolvedValue({ hash: '' }); + + let error: unknown; + try { + await proto.doSwap.call(client, parameters); + } 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(); + }); + + it('leaves a pre-broadcast slippage estimation failure unchanged', async () => { + const client = createClientStub(); + const slippageError = { + reason: 'execution reverted: Too little received', + error: { reason: 'processing response error' }, + }; + client.provider.estimateGas.mockRejectedValue(slippageError); + + let error: unknown; + try { + await proto.doSwap.call(client, parameters); + } catch (e) { + error = e; + } + + expect(error).toBe(slippageError); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(client.wallet.sendTransaction).not.toHaveBeenCalled(); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 9e03aa8c54..b40e905607 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -704,11 +704,10 @@ export abstract class EvmClient extends BlockchainClient { return route; } - private async doSwap(parameters: MethodParameters) { + private async doSwap(parameters: MethodParameters): Promise { const gasPrice = await this.getRecommendedGasPrice(); const nonce = await this.getNonce(this.walletAddress); - - const tx = await this.wallet.sendTransaction({ + const gasLimit = await this.provider.estimateGas({ data: parameters.calldata, to: this.swapContractAddress, value: parameters.value, @@ -717,6 +716,25 @@ export abstract class EvmClient extends BlockchainClient { nonce, }); + // This try begins at the exact broadcast boundary. Gas price, nonce and explicit gas estimation + // above are pre-broadcast; only send failures are ambiguous and must fail closed. + let tx: ethers.providers.TransactionResponse; + try { + tx = await this.wallet.sendTransaction({ + data: parameters.calldata, + to: this.swapContractAddress, + value: parameters.value, + from: this.walletAddress, + gasPrice, + nonce, + 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 }); + } + this.setNonce(this.walletAddress, nonce + 1); return tx.hash; diff --git a/src/subdomains/supporting/dex/entities/__mocks__/liquidity-order.entity.mock.ts b/src/subdomains/supporting/dex/entities/__mocks__/liquidity-order.entity.mock.ts index 662d45e420..d1ae2c89a8 100644 --- a/src/subdomains/supporting/dex/entities/__mocks__/liquidity-order.entity.mock.ts +++ b/src/subdomains/supporting/dex/entities/__mocks__/liquidity-order.entity.mock.ts @@ -9,6 +9,7 @@ export function createDefaultLiquidityOrder(): LiquidityOrder { export function createCustomLiquidityOrder(customValues: Partial): LiquidityOrder { const { + id, type, context, correlationId, @@ -29,6 +30,7 @@ export function createCustomLiquidityOrder(customValues: Partial const keys = Object.keys(customValues); const entity = new LiquidityOrder(); + entity.id = keys.includes('id') ? id : 1; entity.type = keys.includes('type') ? type : LiquidityOrderType.PURCHASE; entity.context = keys.includes('context') ? context : LiquidityOrderContext.BUY_CRYPTO; entity.correlationId = keys.includes('correlationId') ? correlationId : 'CID_01'; diff --git a/src/subdomains/supporting/dex/entities/liquidity-order.entity.ts b/src/subdomains/supporting/dex/entities/liquidity-order.entity.ts index 0a93eb08a8..1d23c05df7 100644 --- a/src/subdomains/supporting/dex/entities/liquidity-order.entity.ts +++ b/src/subdomains/supporting/dex/entities/liquidity-order.entity.ts @@ -24,6 +24,8 @@ export type ChainSwapId = string; export type TargetAmount = number; @Entity() +// IDX_liquidity_order_inflight_purchase is deliberately migration-owned for stable schema management. +// Do not re-declare it here or let schema generation remove the partial unique index. @Index((order: LiquidityOrder) => [order.context, order.correlationId]) export class LiquidityOrder extends IEntity { @Column({ length: 256 }) diff --git a/src/subdomains/supporting/dex/services/__tests__/dex.service.spec.ts b/src/subdomains/supporting/dex/services/__tests__/dex.service.spec.ts new file mode 100644 index 0000000000..278a2b5b9b --- /dev/null +++ b/src/subdomains/supporting/dex/services/__tests__/dex.service.spec.ts @@ -0,0 +1,114 @@ +import { mock } from 'jest-mock-extended'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { LessThan } from 'typeorm'; +import { createCustomLiquidityOrder } from '../../entities/__mocks__/liquidity-order.entity.mock'; +import { LiquidityOrderContext, LiquidityOrderType } from '../../entities/liquidity-order.entity'; +import { LiquidityOrderFactory } from '../../factories/liquidity-order.factory'; +import { LiquidityOrderRepository } from '../../repositories/liquidity-order.repository'; +import { CheckLiquidityStrategyRegistry } from '../../strategies/check-liquidity/impl/base/check-liquidity.strategy-registry'; +import { PurchaseLiquidityStrategyRegistry } from '../../strategies/purchase-liquidity/impl/base/purchase-liquidity.strategy-registry'; +import { SellLiquidityStrategyRegistry } from '../../strategies/sell-liquidity/impl/base/sell-liquidity.strategy-registry'; +import { SupplementaryStrategyRegistry } from '../../strategies/supplementary/impl/base/supplementary.strategy-registry'; +import { DexService } from '../dex.service'; + +describe('DexService', () => { + let service: DexService; + let liquidityOrderRepo: LiquidityOrderRepository; + let notificationService: NotificationService; + + beforeEach(() => { + liquidityOrderRepo = mock(); + notificationService = mock(); + + service = new DexService( + mock(), + mock(), + mock(), + mock(), + liquidityOrderRepo, + mock(), + notificationService, + ); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('selects all stranded purchases and sends one debounced mail grouped by transaction state', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-07-16T12:00:00.000Z')); + const orders = [ + createCustomLiquidityOrder({ id: 9, txId: undefined }), + createCustomLiquidityOrder({ id: 3, txId: 'TX_03' }), + createCustomLiquidityOrder({ id: 4, txId: undefined }), + createCustomLiquidityOrder({ id: 8, txId: 'TX_08' }), + ]; + const findSpy = jest.spyOn(liquidityOrderRepo, 'find').mockResolvedValue(orders); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); + + await service['alertStrandedPurchaseOrders'](); + + expect(findSpy).toHaveBeenCalledWith({ + where: { + type: LiquidityOrderType.PURCHASE, + isComplete: false, + isReady: false, + created: LessThan(new Date('2026-07-16T11:45:00.000Z')), + }, + select: { id: true, txId: true }, + loadEagerRelations: false, + }); + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + errors: [ + 'Purchase liquidity orders have no transaction ID after 15 minutes — verify on-chain absence before cancelling: 4, 9', + 'Purchase liquidity orders were dispatched but never completed — reconcile/complete manually; retries are blocked by the in-flight guard: 3, 8', + ], + }), + options: { debounce: 3600000 }, + }), + ); + }); + + it('fetches the newest order when returning a liquidity transaction result', async () => { + const order = createCustomLiquidityOrder({ id: 7, targetAmount: 5 }); + const findOneSpy = jest.spyOn(liquidityOrderRepo, 'findOne').mockResolvedValue(order); + + const result = await service.fetchLiquidityTransactionResult(LiquidityOrderContext.BUY_CRYPTO, 'CID_01'); + + expect(findOneSpy).toHaveBeenCalledWith({ + where: { context: LiquidityOrderContext.BUY_CRYPTO, correlationId: 'CID_01' }, + order: { id: 'DESC' }, + }); + expect(result.target.amount).toBe(5); + }); + + it('checks readiness on the newest order', async () => { + const order = createCustomLiquidityOrder({ id: 7, isReady: true, txId: 'TX_07' }); + const findOneSpy = jest.spyOn(liquidityOrderRepo, 'findOne').mockResolvedValue(order); + + const result = await service.checkOrderReady(LiquidityOrderContext.BUY_CRYPTO, 'CID_01'); + + expect(findOneSpy).toHaveBeenCalledWith({ + where: { context: LiquidityOrderContext.BUY_CRYPTO, correlationId: 'CID_01' }, + order: { id: 'DESC' }, + }); + expect(result).toEqual(expect.objectContaining({ isReady: true, purchaseTxId: 'TX_07' })); + }); + + it('checks completion on the newest order without excluding completed rows', async () => { + const order = createCustomLiquidityOrder({ id: 7, isComplete: true, txId: 'TX_07' }); + const findOneSpy = jest.spyOn(liquidityOrderRepo, 'findOne').mockResolvedValue(order); + + const result = await service.checkOrderCompletion(LiquidityOrderContext.BUY_CRYPTO, 'CID_01'); + + expect(findOneSpy).toHaveBeenCalledWith({ + where: { context: LiquidityOrderContext.BUY_CRYPTO, correlationId: 'CID_01' }, + order: { id: 'DESC' }, + }); + expect(result).toEqual({ isComplete: true, purchaseTxId: 'TX_07' }); + }); +}); diff --git a/src/subdomains/supporting/dex/services/base/__tests__/dex-evm.service.spec.ts b/src/subdomains/supporting/dex/services/base/__tests__/dex-evm.service.spec.ts new file mode 100644 index 0000000000..26ccd640a1 --- /dev/null +++ b/src/subdomains/supporting/dex/services/base/__tests__/dex-evm.service.spec.ts @@ -0,0 +1,66 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; +import { EvmService } from 'src/integration/blockchain/shared/evm/evm.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PriceSlippageException } from '../../../exceptions/price-slippage.exception'; +import { LiquidityOrderRepository } from '../../../repositories/liquidity-order.repository'; +import { DexEvmService } from '../dex-evm.service'; + +class DexEvmServiceWrapper extends DexEvmService {} + +describe('DexEvmService', () => { + let client: EvmClient; + let service: DexEvmService; + + beforeEach(() => { + client = mock(); + const evmService = mock(); + jest.spyOn(evmService, 'getDefaultClient').mockReturnValue(client); + + service = new DexEvmServiceWrapper(mock(), evmService, 'ETH', Blockchain.ETHEREUM); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('swap(...)', () => { + const swapAsset = createCustomAsset({ dexName: 'USDC' }); + const targetAsset = createCustomAsset({ dexName: 'ETH' }); + + it('maps a direct estimateGas slippage revert to PriceSlippageException', async () => { + const slippageError = { + reason: 'execution reverted: Too little received', + error: { reason: 'processing response error' }, + }; + jest.spyOn(client, 'swap').mockRejectedValue(slippageError); + + let error: unknown; + try { + await service.swap(swapAsset, 2.5, targetAsset, 0.2); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PriceSlippageException); + expect((error as PriceSlippageException).message).toBe( + 'Price is higher than indicated. Composite swap 2.5 USDC to ETH.', + ); + }); + + it('rethrows a non-slippage error unchanged', async () => { + const cause = new Error('RPC unavailable'); + jest.spyOn(client, 'swap').mockRejectedValue(cause); + + let error: unknown; + try { + await service.swap(swapAsset, 2.5, targetAsset, 0.2); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); + }); + }); +}); diff --git a/src/subdomains/supporting/dex/services/base/dex-evm.service.ts b/src/subdomains/supporting/dex/services/base/dex-evm.service.ts index a3afd601c2..27c43ea333 100644 --- a/src/subdomains/supporting/dex/services/base/dex-evm.service.ts +++ b/src/subdomains/supporting/dex/services/base/dex-evm.service.ts @@ -79,9 +79,13 @@ export abstract class DexEvmService implements PurchaseDexService { async swap(swapAsset: Asset, swapAmount: number, targetAsset: Asset, maxSlippage: number): Promise { try { + // EvmClient owns the exact TxBroadcastError boundary around doSwap's wallet.sendTransaction; + // route and preflight failures from client.swap remain plain errors and are safe to retry. return await this.#client.swap(swapAsset, swapAmount, targetAsset, maxSlippage); } catch (e) { - if (e.error?.reason?.includes('Too little received')) { + // Ethers exposes direct estimateGas reverts at e.reason, but historical send-time estimation at e.error.reason. + const revertReasons = [e.reason, e.error?.reason, e.error?.error?.reason]; + if (revertReasons.some((reason) => typeof reason === 'string' && reason.includes('Too little received'))) { throw new PriceSlippageException( `Price is higher than indicated. Composite swap ${swapAmount} ${swapAsset.dexName} to ${targetAsset.dexName}.`, ); diff --git a/src/subdomains/supporting/dex/services/dex.service.ts b/src/subdomains/supporting/dex/services/dex.service.ts index f67870718e..168314cdfb 100644 --- a/src/subdomains/supporting/dex/services/dex.service.ts +++ b/src/subdomains/supporting/dex/services/dex.service.ts @@ -5,7 +5,10 @@ import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.e import { Asset } from 'src/shared/models/asset/asset.entity'; import { DfxLogger, LogLevel } from 'src/shared/services/dfx-logger'; import { DfxCron } from 'src/shared/utils/cron'; -import { IsNull, Not } from 'typeorm'; +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 { IsNull, LessThan, Not } from 'typeorm'; import { LiquidityOrder, LiquidityOrderContext, LiquidityOrderType } from '../entities/liquidity-order.entity'; import { LiquidityOrderNotReadyException } from '../exceptions/liquidity-order-not-ready.exception'; import { NotEnoughLiquidityException } from '../exceptions/not-enough-liquidity.exception'; @@ -41,6 +44,7 @@ export class DexService { private readonly liquidityOrderRepo: LiquidityOrderRepository, private readonly liquidityOrderFactory: LiquidityOrderFactory, + private readonly notificationService: NotificationService, ) {} // *** MAIN PUBLIC API *** // @@ -154,7 +158,11 @@ export class DexService { context: LiquidityOrderContext, correlationId: string, ): Promise { - const order = await this.liquidityOrderRepo.findOneBy({ context, correlationId }); + // Cancelled purchase attempts remain persisted for the audit trail, so the newest row is authoritative. + const order = await this.liquidityOrderRepo.findOne({ + where: { context, correlationId }, + order: { id: 'DESC' }, + }); if (!order) { throw new Error(`Order not found. Context: ${context}. Correlation ID: ${correlationId}.`); @@ -171,7 +179,10 @@ export class DexService { context: LiquidityOrderContext, correlationId: string, ): Promise<{ isReady: boolean; purchaseTxId: string; targetAmount: number; targetAsset: string }> { - const order = await this.liquidityOrderRepo.findOneBy({ context, correlationId }); + const order = await this.liquidityOrderRepo.findOne({ + where: { context, correlationId }, + order: { id: 'DESC' }, + }); const purchaseTxId = order?.txId; const isReady = order?.isReady ?? false; @@ -185,7 +196,10 @@ export class DexService { context: LiquidityOrderContext, correlationId: string, ): Promise<{ isComplete: boolean; purchaseTxId: string }> { - const order = await this.liquidityOrderRepo.findOneBy({ context, correlationId }); + const order = await this.liquidityOrderRepo.findOne({ + where: { context, correlationId }, + order: { id: 'DESC' }, + }); const purchaseTxId = order && order.txId; const isComplete = order && order.isComplete; @@ -310,6 +324,8 @@ export class DexService { //*** JOBS ***// @DfxCron(CronExpression.EVERY_30_SECONDS, { timeout: 1800 }) async finalizePurchaseOrders(): Promise { + await this.alertStrandedPurchaseOrders(); + const standingOrders = await this.liquidityOrderRepo.findBy({ isReady: false, txId: Not(IsNull()), @@ -320,6 +336,57 @@ export class DexService { // *** HELPER METHODS *** // + private async alertStrandedPurchaseOrders(): Promise { + // Only not-yet-ready in-flight purchases are stranded; ready orders are progressing toward batch completion. + const orders = await this.liquidityOrderRepo.find({ + where: { + type: LiquidityOrderType.PURCHASE, + isComplete: false, + isReady: false, + created: LessThan(Util.minutesBefore(15)), + }, + select: { id: true, txId: true }, + loadEagerRelations: false, + }); + + if (orders.length === 0) return; + + const idsWithoutTxId = orders + .filter((order) => !order.txId) + .map((order) => order.id) + .sort((a, b) => a - b); + const idsWithTxId = orders + .filter((order) => order.txId) + .map((order) => order.id) + .sort((a, b) => a - b); + const errors: string[] = []; + + if (idsWithoutTxId.length > 0) { + errors.push( + `Purchase liquidity orders have no transaction ID after 15 minutes — verify on-chain absence before cancelling: ${idsWithoutTxId.join(', ')}`, + ); + } + if (idsWithTxId.length > 0) { + errors.push( + `Purchase liquidity orders were dispatched but never completed — reconcile/complete manually; retries are blocked by the in-flight guard: ${idsWithTxId.join(', ')}`, + ); + } + + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + context: MailContext.DEX, + input: { + subject: 'Stranded In-Flight Liquidity Purchases', + errors, + isLiqMail: true, + }, + correlationId: 'StrandedInflightLiquidityPurchases', + // Debounce only: the operator gets a recurring hourly signal while the incident remains open, + // without receiving one mail on every 30-second cron run. + options: { debounce: 3600000 }, + }); + } + private handleCheckLiquidityResult(liquidity: CheckLiquidityResult): void { const { metadata, target } = liquidity; if (!metadata.isEnoughAvailableLiquidity) { diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase.strategy.spec.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase.strategy.spec.ts new file mode 100644 index 0000000000..5d0f0649e1 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase.strategy.spec.ts @@ -0,0 +1,125 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { createCustomLiquidityOrder } from '../../../entities/__mocks__/liquidity-order.entity.mock'; +import { LiquidityOrder } from '../../../entities/liquidity-order.entity'; +import { LiquidityOrderFactory } from '../../../factories/liquidity-order.factory'; +import { createDefaultGetLiquidityRequest } from '../../../interfaces/__mocks__/liquidity-request.mock'; +import { LiquidityOrderRepository } from '../../../repositories/liquidity-order.repository'; +import { PurchaseDexService, PurchaseStrategy } from '../impl/base/purchase.strategy'; + +class TestPurchaseStrategy extends PurchaseStrategy { + protected readonly logger = new DfxLogger(TestPurchaseStrategy); + + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get assetCategory(): AssetCategory { + return AssetCategory.PUBLIC; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return Promise.resolve(createDefaultAsset()); + } +} + +describe('PurchaseStrategy', () => { + let strategy: TestPurchaseStrategy; + let dexService: PurchaseDexService; + let liquidityOrderRepo: LiquidityOrderRepository; + let liquidityOrderFactory: LiquidityOrderFactory; + let order: LiquidityOrder; + let saveSpy: jest.SpyInstance; + let swapSpy: jest.SpyInstance; + let estimateSpy: jest.SpyInstance; + + beforeEach(() => { + dexService = mock(); + liquidityOrderRepo = mock(); + liquidityOrderFactory = mock(); + order = createCustomLiquidityOrder({ id: 42, txId: undefined, swapAsset: undefined, swapAmount: undefined }); + + jest.spyOn(liquidityOrderFactory, 'createPurchaseOrder').mockReturnValue(order); + saveSpy = jest.spyOn(liquidityOrderRepo, 'save').mockImplementation(async (entity) => entity as LiquidityOrder); + swapSpy = jest.spyOn(dexService, 'swap').mockResolvedValue('SWAP_TX_01'); + estimateSpy = jest.spyOn(dexService, 'getTargetAmount').mockResolvedValue(2); + + strategy = new TestPurchaseStrategy(dexService); + Object.assign(strategy, { liquidityOrderRepo, liquidityOrderFactory }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('persists the in-flight order before dispatching the swap', async () => { + await strategy.purchaseLiquidity(createDefaultGetLiquidityRequest()); + + expect(saveSpy.mock.invocationCallOrder[0]).toBeLessThan(swapSpy.mock.invocationCallOrder[0]); + }); + + it('cancels and persists the order when swap booking fails before the broadcast boundary', async () => { + const error = new Error('route lookup failed'); + const cancelSpy = jest.spyOn(order, 'cancel'); + swapSpy.mockRejectedValue(error); + + await expect(strategy.purchaseLiquidity(createDefaultGetLiquidityRequest())).rejects.toBe(error); + + expect(cancelSpy).toHaveBeenCalledTimes(1); + expect(order.isComplete).toBe(true); + expect(saveSpy).toHaveBeenCalledTimes(2); + expect(saveSpy).toHaveBeenLastCalledWith(order); + }); + + it('keeps the order in-flight when swap dispatch fails ambiguously', async () => { + const error = new TxBroadcastError('send result unknown'); + const cancelSpy = jest.spyOn(order, 'cancel'); + swapSpy.mockRejectedValue(error); + + await expect(strategy.purchaseLiquidity(createDefaultGetLiquidityRequest())).rejects.toBe(error); + + expect(cancelSpy).not.toHaveBeenCalled(); + expect(order.isComplete).toBe(false); + expect(saveSpy).toHaveBeenCalledTimes(1); + }); + + it('persists the transaction ID immediately after a successful swap and before estimation', async () => { + const persistedTxIds: string[] = []; + saveSpy.mockImplementation(async (entity: LiquidityOrder) => { + persistedTxIds.push(entity.txId); + return entity; + }); + await strategy.purchaseLiquidity(createDefaultGetLiquidityRequest()); + + expect(persistedTxIds).toEqual([undefined, 'SWAP_TX_01', 'SWAP_TX_01']); + expect(saveSpy.mock.invocationCallOrder[1]).toBeLessThan(estimateSpy.mock.invocationCallOrder[0]); + }); + + it('leaves the persisted transaction ID in-flight when post-swap estimation fails', async () => { + const persistedTxIds: string[] = []; + const cancelSpy = jest.spyOn(order, 'cancel'); + saveSpy.mockImplementation(async (entity: LiquidityOrder) => { + persistedTxIds.push(entity.txId); + return entity; + }); + estimateSpy.mockRejectedValue(new Error('quote unavailable')); + + await expect(strategy.purchaseLiquidity(createDefaultGetLiquidityRequest())).rejects.toThrow('quote unavailable'); + + expect(persistedTxIds).toEqual([undefined, 'SWAP_TX_01']); + expect(cancelSpy).not.toHaveBeenCalled(); + expect(order.isComplete).toBe(false); + }); +}); diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/base/purchase.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/base/purchase.strategy.ts index f6b291e572..851c67fb9f 100644 --- a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/base/purchase.strategy.ts +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/base/purchase.strategy.ts @@ -1,3 +1,4 @@ +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { ChainSwapId, LiquidityOrder } from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; import { PurchaseLiquidityRequest } from '../../../../interfaces'; @@ -19,10 +20,38 @@ export abstract class PurchaseStrategy extends PurchaseLiquidityStrategy { async purchaseLiquidity(request: PurchaseLiquidityRequest): Promise { const order = this.liquidityOrderFactory.createPurchaseOrder(request, this.blockchain, this.constructor.name); + // Persist BEFORE the swap (fail-closed, mirrors payout #4181): the row is the in-flight + // marker, and the partial unique index rejects a concurrent duplicate purchase for the same + // (context, correlationId) here, before anything reaches the chain. + await this.liquidityOrderRepo.save(order); + try { await this.bookLiquiditySwap(order); - await this.estimateTargetAmount(order); + } catch (e) { + if (e instanceof TxBroadcastError || order.txId != null) { + // The dispatch boundary was reached (or a txId was already returned), so the transaction may + // be in-flight. Keep the marker unchanged; its unique index guard blocks a blind re-purchase + // until an operator investigates. + throw e; + } + + // A plain error occurred before the send boundary. Cancelling removes this completed row from + // the partial index and preserves the existing retry-on-next-cron behavior. + order.cancel(); + await this.liquidityOrderRepo.save(order); + throw e; + } + + // Persist the transaction ID as soon as the dispatch returns. Estimation is deliberately later: + // if it fails, finalizePurchaseOrders can still pick up the already-broadcast transaction. + await this.liquidityOrderRepo.save(order); + this.logger.verbose( + `Booked purchase of ${order.referenceAmount} ${order.referenceAsset.dexName} worth liquidity for asset ${order.targetAsset.dexName}. Context: ${order.context}. CorrelationId: ${order.correlationId}.`, + ); + + try { + await this.estimateTargetAmount(order); await this.liquidityOrderRepo.save(order); } catch (e) { await this.handlePurchaseLiquidityError(e, request); @@ -43,11 +72,6 @@ export abstract class PurchaseStrategy extends PurchaseLiquidityStrategy { const { referenceAsset, referenceAmount, targetAsset, maxPriceSlippage } = order; const txId = await this.dexService.swap(referenceAsset, referenceAmount, targetAsset, maxPriceSlippage); - - this.logger.verbose( - `Booked purchase of ${referenceAmount} ${referenceAsset.dexName} worth liquidity for asset ${order.targetAsset.dexName}. Context: ${order.context}. CorrelationId: ${order.correlationId}.`, - ); - order.addBlockchainTransactionMetadata(txId, referenceAsset, referenceAmount); } From b65efc289523d51d1b17a0857ddec35e39d3b609 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:38:15 +0200 Subject: [PATCH 08/19] fix(payin): designate EVM sends before broadcasting (double-send crash window) (#4249) * fix(payin): designate EVM sends before broadcasting (double-send crash window) The EVM forwarding/return path broadcast first and persisted afterwards; the minute cron re-selects on status + outTxId IS NULL, so a crash between send and save re-broadcast the whole group. Mirrors the payout designation fix: a persisted Sending marker is written per group member before the broadcast, provably pre-broadcast errors restore the captured statuses for auto-retry, ambiguous broadcast failures keep Sending, and a cron escalation moves stranded entries to SendUncertain with one monitoring mail. The EIP-7702 delegation path gets the same barrier plus a proper broadcast boundary around the relayer send. No migration needed (status column is varchar). Part 1 of #4192 * style: format payin service and token strategy specs * test: type the payin save mock resolver * test: import the entity type for the payin save mock * test: type the remaining payin save mock resolvers * test: type the block-body payin save mock as well * test: type the payin service save mock resolver * fix(payin): fail closed after a tx id exists and harden the send escalation Review follow-ups: the restore path now only fires while no tx id was obtained - a plain persistence error after a successful broadcast keeps the group in Sending (and logs the tx id) instead of re-arming it for a second broadcast. The stranded-send escalation is age-gated (ten minutes; the two send crons run concurrently and must not escalate each other's live work) and escalates via atomic conditional transitions instead of stale full-entity saves that could overwrite a concurrently persisted outTxId; the mail lists only actually escalated entries, logs an error and reaches the liquidity recipients. Refund paths reject Sending/SendUncertain pay-ins, the finance log keeps both statuses in its pending set, and the impossible mixed-group narrative in comments/fixtures is gone. * style: format payin token strategy * refactor(payin): shared in-flight-send guard constant and conformity cleanups Review follow-ups: the in-flight/uncertain guard lives as an exported constant next to CryptoInputSettledStatus and both refund guards use it with a message in the house style; the orphaned sendUncertain() helper (escalation writes conditionally) is removed; spec imports are sorted and the token strategy spec header describes the file again. --- .../delegation/eip7702-delegation.service.ts | 27 ++- .../__tests__/buy-fiat.service.spec.ts | 19 ++ .../process/services/buy-fiat.service.ts | 11 +- .../__tests__/crypto-input.entity.spec.ts | 11 ++ .../payin/entities/crypto-input.entity.ts | 11 ++ .../services/__tests__/payin.service.spec.ts | 180 ++++++++++++++++++ .../payin/services/payin.service.ts | 66 ++++++- .../impl/base/__tests__/evm.strategy.spec.ts | 77 ++++++++ .../base/__tests__/evm.token.strategy.spec.ts | 115 ++++++++++- .../strategies/send/impl/base/evm.strategy.ts | 41 +++- .../send/impl/base/evm.token.strategy.ts | 46 ++++- 11 files changed, 577 insertions(+), 27 deletions(-) create mode 100644 src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts diff --git a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts index 3edd6feec6..08172fb6e0 100644 --- a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts +++ b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Config, Environment, GetConfig } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { @@ -805,15 +806,23 @@ export class Eip7702DelegationService { `from ${depositAddress} to ${recipient} (gasLimit: ${gasLimit}, estimatedCost: ~${estimatedGasCost} native)`, ); - // Send transaction to DelegationManager with authorization - const txHash = await walletClient.sendTransaction({ - to: DELEGATION_MANAGER_ADDRESS, - data: redeemData, - authorizationList: [authorization], - gas: gasLimit, - maxFeePerGas, - maxPriorityFeePerGas, - } as any); + // Keep validation, signing and fee estimation errors distinguishable as pre-broadcast. Only + // failures at the actual send boundary are ambiguous and must make the pay-in fail closed. + let txHash: Hex; + try { + txHash = await walletClient.sendTransaction({ + to: DELEGATION_MANAGER_ADDRESS, + data: redeemData, + authorizationList: [authorization], + gas: gasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + } as any); + + if (!txHash) throw new Error('Broadcast returned an empty tx hash'); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } this.logger.info( `Delegation transfer via DelegationManager successful on ${blockchain}: ` + diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts index 33a6a79633..77a1635433 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts @@ -22,6 +22,7 @@ import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/service import { createCustomFiatOutput } from 'src/subdomains/supporting/fiat-output/__mocks__/fiat-output.entity.mock'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; +import { PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; @@ -187,6 +188,24 @@ describe('BuyFiatService', () => { expect(service).toBeDefined(); }); + it.each([PayInStatus.SENDING, PayInStatus.SEND_UNCERTAIN])( + 'does not re-arm a buy-fiat return while the pay-in send status is %s', + async (status) => { + const buyFiat = createCustomBuyFiat({ + id: 71, + chargebackAddress: '0x0000000000000000000000000000000000000001', + chargebackAmount: 0.1, + }); + const cryptoInput = createCustomCryptoInput({ status, returnTxId: null }); + + await expect(service['triggerBuyFiatReturn'](buyFiat, cryptoInput)).rejects.toThrow( + new BadRequestException('CryptoInput send in flight or uncertain'), + ); + + expect(payInService.returnPayIn).not.toHaveBeenCalled(); + }, + ); + it('should return an empty array, if sell route has no history', async () => { setup(MockBuyData.BUY_HISTORY_EMPTY); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index 81e2caba41..1e1ca647e4 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -21,7 +21,12 @@ import { UserDataService } from 'src/subdomains/generic/user/models/user-data/us import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { WebhookService } from 'src/subdomains/generic/user/services/webhook/webhook.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; -import { CryptoInput, PayInAction, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { + CryptoInput, + CryptoInputInFlightSendStatus, + PayInAction, + PayInStatus, +} from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { TransactionTypeInternal } from 'src/subdomains/supporting/payment/entities/transaction.entity'; @@ -296,8 +301,10 @@ export class BuyFiatService implements OnModuleInit { const { chargebackAddress, chargebackAmount } = buyFiat; if (!chargebackAddress || !chargebackAmount || !cryptoInput?.asset) return; + if (CryptoInputInFlightSendStatus.includes(cryptoInput.status)) + throw new BadRequestException('CryptoInput send in flight or uncertain'); - // Skip if return already in progress or completed + // Skip if a return is already in progress or completed. if ( [PayInStatus.TO_RETURN, PayInStatus.RETURNED, PayInStatus.RETURN_CONFIRMED].includes(cryptoInput.status) || cryptoInput.returnTxId diff --git a/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts b/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts index 926aad77c2..ca6724c5c4 100644 --- a/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts +++ b/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts @@ -2,6 +2,17 @@ import { createCustomCryptoInput } from '../__mocks__/crypto-input.entity.mock'; import { PayInStatus } from '../crypto-input.entity'; describe('CryptoInput', () => { + describe('#designateSending(...)', () => { + it('sets status to PayInStatus.SENDING', () => { + const entity = createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }); + + const result = entity.designateSending(); + + expect(result).toBe(entity); + expect(entity.status).toBe(PayInStatus.SENDING); + }); + }); + describe('#fail(...)', () => { it('sets status to PayInStatus.FAILED', () => { const entity = createCustomCryptoInput({ id: 1, status: PayInStatus.ACKNOWLEDGED }); diff --git a/src/subdomains/supporting/payin/entities/crypto-input.entity.ts b/src/subdomains/supporting/payin/entities/crypto-input.entity.ts index 588576550c..5bb5d5a067 100644 --- a/src/subdomains/supporting/payin/entities/crypto-input.entity.ts +++ b/src/subdomains/supporting/payin/entities/crypto-input.entity.ts @@ -44,6 +44,8 @@ export enum PayInStatus { FORWARD_CONFIRMED = 'ForwardConfirmed', PREPARING = 'Preparing', PREPARED = 'Prepared', + SENDING = 'Sending', + SEND_UNCERTAIN = 'SendUncertain', COMPLETED = 'Completed', } @@ -243,6 +245,12 @@ export class CryptoInput extends IEntity { return this; } + designateSending(): this { + this.status = PayInStatus.SENDING; + + return this; + } + forward(outTxId: string, forwardFeeAmount?: number, feeAmountChf?: number): this { this.outTxId = outTxId; @@ -367,3 +375,6 @@ export class CryptoInput extends IEntity { } export const CryptoInputSettledStatus = [PayInStatus.FORWARD_CONFIRMED, PayInStatus.COMPLETED]; + +// Send in flight or unresolved — a return must not re-arm it. +export const CryptoInputInFlightSendStatus = [PayInStatus.SENDING, PayInStatus.SEND_UNCERTAIN]; diff --git a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts new file mode 100644 index 0000000000..eea0e1d954 --- /dev/null +++ b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts @@ -0,0 +1,180 @@ +import { BadRequestException } from '@nestjs/common'; +import { mock } from 'jest-mock-extended'; +import { ConfigService } from 'src/config/config'; +import { Util } from 'src/shared/utils/util'; +import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { In, IsNull, LessThan, Not } from 'typeorm'; +import { createCustomCryptoInput } from '../../entities/__mocks__/crypto-input.entity.mock'; +import { PayInAction, PayInStatus, PayInType } from '../../entities/crypto-input.entity'; +import { PayInRepository } from '../../repositories/payin.repository'; +import { RegisterStrategyRegistry } from '../../strategies/register/impl/base/register.strategy-registry'; +import { SendStrategyRegistry } from '../../strategies/send/impl/base/send.strategy-registry'; +import { PayInBitcoinService } from '../payin-bitcoin.service'; +import { PayInFiroService } from '../payin-firo.service'; +import { PayInService } from '../payin.service'; + +describe('PayInService designate-before-broadcast safeguards', () => { + let service: PayInService; + let payInRepository: PayInRepository; + let notificationService: NotificationService; + + beforeAll(() => { + new ConfigService(); + }); + + beforeEach(() => { + payInRepository = mock(); + notificationService = mock(); + service = new PayInService( + payInRepository, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + notificationService, + ); + }); + + it('conditionally escalates old Sending entries and mails only the IDs whose transition won', async () => { + const cutoff = new Date('2026-07-16T10:00:00.000Z'); + const payIns = [ + createCustomCryptoInput({ id: 41, status: PayInStatus.SENDING }), + createCustomCryptoInput({ id: 42, status: PayInStatus.SENDING }), + createCustomCryptoInput({ id: 43, status: PayInStatus.SENDING }), + ]; + const minutesBeforeSpy = jest.spyOn(Util, 'minutesBefore').mockReturnValueOnce(cutoff); + jest.spyOn(payInRepository, 'find').mockResolvedValue(payIns); + const updateSpy = jest + .spyOn(payInRepository, 'update') + .mockResolvedValueOnce({ affected: 1 } as any) + .mockResolvedValueOnce({ affected: 0 } as any) + .mockRejectedValueOnce(new Error('deadlock')); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); + const errorSpy = jest.spyOn(service['logger'], 'error').mockImplementation(); + const warnSpy = jest.spyOn(service['logger'], 'warn').mockImplementation(); + + await service['processStrandedSendingPayIns'](); + + expect(minutesBeforeSpy).toHaveBeenCalledWith(10); + expect(payInRepository.find).toHaveBeenCalledWith({ + where: { status: PayInStatus.SENDING, updated: LessThan(cutoff) }, + select: { id: true }, + loadEagerRelations: false, + }); + expect(updateSpy).toHaveBeenNthCalledWith( + 1, + { id: 41, status: PayInStatus.SENDING }, + { status: PayInStatus.SEND_UNCERTAIN }, + ); + expect(updateSpy).toHaveBeenNthCalledWith( + 2, + { id: 42, status: PayInStatus.SENDING }, + { status: PayInStatus.SEND_UNCERTAIN }, + ); + expect(updateSpy).toHaveBeenNthCalledWith( + 3, + { id: 43, status: PayInStatus.SENDING }, + { status: PayInStatus.SEND_UNCERTAIN }, + ); + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(sendMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + errors: ['Pay-ins left in Sending require manual investigation: 41'], + isLiqMail: true, + }), + correlationId: '|41|', + options: { suppressRecurring: true }, + }), + ); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('41')); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it('does not send mail when no Sending entries are old enough to be stranded', async () => { + jest.spyOn(payInRepository, 'find').mockResolvedValue([]); + const updateSpy = jest.spyOn(payInRepository, 'update'); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail'); + + await service['processStrandedSendingPayIns'](); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(sendMailSpy).not.toHaveBeenCalled(); + }); + + it.each([PayInStatus.SENDING, PayInStatus.SEND_UNCERTAIN])( + 'rejects returnPayIn while the send status is %s', + async (status) => { + const payIn = createCustomCryptoInput({ + id: 51, + status, + action: PayInAction.WAITING, + returnTxId: null, + }); + + await expect(service.returnPayIn(payIn, '0x0000000000000000000000000000000000000001', 0.1)).rejects.toThrow( + new BadRequestException('CryptoInput send in flight or uncertain'), + ); + + expect(payInRepository.save).not.toHaveBeenCalled(); + }, + ); + + it('keeps Sending and SendUncertain in the finance-log pending set', async () => { + const findBySpy = jest.spyOn(payInRepository, 'findBy').mockResolvedValue([]); + + await service.getPendingPayIns(); + + expect(findBySpy).toHaveBeenCalledWith({ + status: In([ + PayInStatus.ACKNOWLEDGED, + PayInStatus.FORWARDED, + PayInStatus.RETURNED, + PayInStatus.TO_RETURN, + PayInStatus.SENDING, + PayInStatus.SEND_UNCERTAIN, + ]), + isConfirmed: true, + txType: Not(PayInType.PAYMENT), + }); + }); + + it('keeps the forward query restricted to Acknowledged, Preparing and Prepared', async () => { + const findSpy = jest.spyOn(payInRepository, 'find').mockResolvedValue([]); + + await service['forwardPayIns'](); + + expect(findSpy).toHaveBeenCalledWith({ + where: { + status: In([PayInStatus.ACKNOWLEDGED, PayInStatus.PREPARING, PayInStatus.PREPARED]), + action: PayInAction.FORWARD, + outTxId: IsNull(), + asset: Not(IsNull()), + isConfirmed: true, + }, + relations: { buyCrypto: true, buyFiat: true }, + }); + }); + + it('keeps the return query restricted to ToReturn, Preparing and Prepared', async () => { + const findSpy = jest.spyOn(payInRepository, 'find').mockResolvedValue([]); + + await service['returnPayIns'](); + + expect(findSpy).toHaveBeenCalledWith({ + where: { + status: In([PayInStatus.TO_RETURN, PayInStatus.PREPARING, PayInStatus.PREPARED]), + action: PayInAction.RETURN, + returnTxId: IsNull(), + asset: Not(IsNull()), + chargebackAmount: Not(IsNull()), + isConfirmed: true, + }, + relations: { buyCrypto: true, buyFiat: true }, + }); + }); +}); diff --git a/src/subdomains/supporting/payin/services/payin.service.ts b/src/subdomains/supporting/payin/services/payin.service.ts index be75df3051..89b2befd06 100644 --- a/src/subdomains/supporting/payin/services/payin.service.ts +++ b/src/subdomains/supporting/payin/services/payin.service.ts @@ -13,12 +13,15 @@ import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; import { Staking } from 'src/subdomains/core/staking/entities/staking.entity'; -import { In, IsNull, MoreThan, Not } from 'typeorm'; +import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; import { DepositRoute } from '../../address-pool/route/deposit-route.entity'; import { TransactionSourceType, TransactionTypeInternal } from '../../payment/entities/transaction.entity'; import { TransactionService } from '../../payment/services/transaction.service'; import { CryptoInput, + CryptoInputInFlightSendStatus, PayInAction, PayInConfirmationType, PayInPurpose, @@ -45,6 +48,7 @@ export class PayInService { private readonly paymentLinkPaymentService: PaymentLinkPaymentService, private readonly payInBitcoinService: PayInBitcoinService, private readonly payInFiroService: PayInFiroService, + private readonly notificationService: NotificationService, ) {} // --- PUBLIC API --- // @@ -183,8 +187,16 @@ export class PayInService { } async getPendingPayIns(): Promise { + // SendUncertain can remain unresolved for days and, like Sending, must stay in pending balances. return this.payInRepository.findBy({ - status: In([PayInStatus.ACKNOWLEDGED, PayInStatus.FORWARDED, PayInStatus.RETURNED, PayInStatus.TO_RETURN]), + status: In([ + PayInStatus.ACKNOWLEDGED, + PayInStatus.FORWARDED, + PayInStatus.RETURNED, + PayInStatus.TO_RETURN, + PayInStatus.SENDING, + PayInStatus.SEND_UNCERTAIN, + ]), isConfirmed: true, txType: Not(PayInType.PAYMENT), }); @@ -219,6 +231,8 @@ export class PayInService { async returnPayIn(payIn: CryptoInput, returnAddress: string, chargebackAmount: number): Promise { if (payIn.action === PayInAction.FORWARD) throw new BadRequestException('CryptoInput already forwarded'); + if (CryptoInputInFlightSendStatus.includes(payIn.status)) + throw new BadRequestException('CryptoInput send in flight or uncertain'); if ([PayInStatus.RETURN_CONFIRMED, PayInStatus.RETURNED].includes(payIn.status) || payIn.returnTxId) throw new BadRequestException('CryptoInput already returned'); @@ -246,11 +260,13 @@ export class PayInService { @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) async forwardPayInEntries(): Promise { await this.forwardPayIns(); + await this.processStrandedSendingPayIns(); } @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) async returnPayInEntries(): Promise { await this.returnPayIns(); + await this.processStrandedSendingPayIns(); } @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) @@ -447,6 +463,52 @@ export class PayInService { } } + private async processStrandedSendingPayIns(): Promise { + // An in-flight dispatch holds Sending for seconds. Ten minutes only matches crash leftovers or + // ambiguous-broadcast strandings, never the other pay-in cron's live work. + const payIns = await this.payInRepository.find({ + where: { status: PayInStatus.SENDING, updated: LessThan(Util.minutesBefore(10)) }, + select: { id: true }, + loadEagerRelations: false, + }); + + if (payIns.length === 0) return; + + const ids: number[] = []; + for (const payIn of payIns) { + try { + const { affected } = await this.payInRepository.update( + { id: payIn.id, status: PayInStatus.SENDING }, + { status: PayInStatus.SEND_UNCERTAIN }, + ); + + if (affected === 1) { + ids.push(payIn.id); + } else { + this.logger.warn(`Skipped escalation of pay-in ${payIn.id}: Sending status changed concurrently`); + } + } catch (e) { + this.logger.warn(`Failed to escalate stranded Sending pay-in ${payIn.id}:`, e); + } + } + + if (ids.length === 0) return; + + const errorMessage = `Pay-ins left in Sending require manual investigation: ${ids.join(', ')}`; + this.logger.error(errorMessage); + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + context: MailContext.MONITORING, + input: { + subject: 'Pay-in send uncertain', + errors: [errorMessage], + isLiqMail: true, + }, + correlationId: ids.map((id) => `|${id}|`).join(''), + options: { suppressRecurring: true }, + }); + } + private async checkInputConfirmations(): Promise { const payIns = await this.payInRepository.findBy({ isConfirmed: false, diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/evm.strategy.spec.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/evm.strategy.spec.ts index be5c77b5e5..729cfa1554 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/evm.strategy.spec.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/evm.strategy.spec.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ethers } from 'ethers'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { BlockchainAddress } from 'src/shared/models/blockchain-address'; @@ -10,6 +11,7 @@ import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { TestUtil } from 'src/shared/utils/test.util'; import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; import { + CryptoInput, PayInConfirmationType, PayInPurpose, PayInStatus, @@ -20,6 +22,7 @@ import { TransactionHelper } from 'src/subdomains/supporting/payment/services/tr import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { EvmStrategy } from '../evm.strategy'; +import { SendGroup, SendType } from '../send.strategy'; import { SendStrategyRegistry } from '../send.strategy-registry'; @Injectable() @@ -150,4 +153,78 @@ describe('EvmStrategy', () => { expect(payInRepo.update).toHaveBeenCalledWith(1, { isConfirmed: true, status: undefined }); }); }); + + describe('dispatch designate-before-broadcast', () => { + function createGroup() { + const payIns = [ + createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }), + createCustomCryptoInput({ id: 2, status: PayInStatus.PREPARED }), + ]; + const group = { + payIns, + status: PayInStatus.PREPARED, + asset: payIns[0].asset, + } as SendGroup; + + return { group, payIns }; + } + + it('persists Sending on every group member before calling the broadcast sink', async () => { + const { group, payIns } = createGroup(); + const statusesAtSave: Array = []; + const saveSpy = jest.spyOn(payInRepo, 'save').mockImplementation(async (payIn) => { + statusesAtSave.push(payIn.status as PayInStatus); + return payIn as CryptoInput; + }); + const broadcastError = new TxBroadcastError('broadcast failed'); + const dispatchSpy = jest.spyOn(strategy as any, 'dispatchSend').mockRejectedValue(broadcastError); + + await expect(strategy['dispatch'](group, SendType.FORWARD, 0.01)).rejects.toBe(broadcastError); + + expect(statusesAtSave).toEqual([PayInStatus.SENDING, PayInStatus.SENDING]); + expect(payIns.every((payIn) => payIn.status === PayInStatus.SENDING)).toBe(true); + expect(saveSpy.mock.invocationCallOrder[1]).toBeLessThan(dispatchSpy.mock.invocationCallOrder[0]); + }); + + it('restores each captured status and rethrows a plain pre-broadcast error', async () => { + const { group, payIns } = createGroup(); + const preBroadcastError = new Error('fee lookup failed'); + jest.spyOn(payInRepo, 'save').mockImplementation(async (payIn) => payIn as CryptoInput); + jest.spyOn(strategy as any, 'dispatchSend').mockRejectedValue(preBroadcastError); + + await expect(strategy['dispatch'](group, SendType.FORWARD, 0.01)).rejects.toBe(preBroadcastError); + + expect(payIns.map((payIn) => payIn.status)).toEqual([PayInStatus.PREPARED, PayInStatus.PREPARED]); + expect(payInRepo.save).toHaveBeenCalledTimes(4); + }); + + it('keeps every member Sending and rethrows an ambiguous TxBroadcastError', async () => { + const { group, payIns } = createGroup(); + const broadcastError = new TxBroadcastError('RPC timeout'); + jest.spyOn(payInRepo, 'save').mockImplementation(async (payIn) => payIn as CryptoInput); + jest.spyOn(strategy as any, 'dispatchSend').mockRejectedValue(broadcastError); + + await expect(strategy['dispatch'](group, SendType.FORWARD, 0.01)).rejects.toBe(broadcastError); + + expect(payIns.map((payIn) => payIn.status)).toEqual([PayInStatus.SENDING, PayInStatus.SENDING]); + expect(payInRepo.save).toHaveBeenCalledTimes(2); + }); + + it('keeps every member Sending and logs the tx id when persistence fails after dispatch', async () => { + const { group, payIns } = createGroup(); + const outTxId = '0xdispatched'; + const persistenceError = new Error('query timeout'); + jest.spyOn(payInRepo, 'save').mockImplementation(async (payIn) => payIn as CryptoInput); + jest.spyOn(strategy as any, 'dispatchSend').mockResolvedValue(outTxId); + jest.spyOn(strategy as any, 'updatePayInsWithSendData').mockRejectedValue(persistenceError); + const logSpy = jest.spyOn(strategy['logger'], 'error').mockImplementation(); + + await expect(strategy['dispatch'](group, SendType.FORWARD, 0.01)).rejects.toBe(persistenceError); + + expect(payIns.map((payIn) => payIn.status)).toEqual([PayInStatus.SENDING, PayInStatus.SENDING]); + expect(payInRepo.save).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(outTxId), persistenceError); + expect(logSpy.mock.calls[0][0]).toContain('1, 2'); + }); + }); }); diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/evm.token.strategy.spec.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/evm.token.strategy.spec.ts index cd8519427e..61ac29f4d3 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/evm.token.strategy.spec.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/evm.token.strategy.spec.ts @@ -1,12 +1,117 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { Eip7702DelegationService } from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; +import { CryptoInput, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayInRepository } from 'src/subdomains/supporting/payin/repositories/payin.repository'; +import { PayInEvmService } from 'src/subdomains/supporting/payin/services/base/payin-evm.service'; +import { EvmTokenStrategy } from '../evm.token.strategy'; +import { SendGroup, SendType } from '../send.strategy'; + /** - * Integration tests for EvmTokenStrategy delegation flow - * - * These tests verify the correct integration between EvmTokenStrategy and Eip7702DelegationService. - * Since the CryptoInput entity has deep import chains, we test the key integration points - * by directly testing the protected methods with minimal mocking. + * EvmTokenStrategy delegation tests: the designate-before-broadcast barrier of + * dispatchViaDelegation (using the real CryptoInput entity and its mocks) plus the + * integration surface between EvmTokenStrategy and Eip7702DelegationService. */ +class TestEvmTokenStrategy extends EvmTokenStrategy { + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + protected getForwardAddress(): BlockchainAddress { + return BlockchainAddress.create('0x0000000000000000000000000000000000000001', this.blockchain); + } +} + describe('EvmTokenStrategy Delegation Integration', () => { + describe('dispatchViaDelegation designate-before-broadcast', () => { + let strategy: TestEvmTokenStrategy; + let payInRepo: PayInRepository; + let delegationService: Eip7702DelegationService; + let saveSpy: jest.SpyInstance; + + function createGroup() { + const payIns = [ + createCustomCryptoInput({ id: 1, status: PayInStatus.ACKNOWLEDGED, amount: 1 }), + createCustomCryptoInput({ id: 2, status: PayInStatus.ACKNOWLEDGED, amount: 2 }), + ]; + const group = { + account: {} as SendGroup['account'], + sourceAddress: '0x0000000000000000000000000000000000000002', + destinationAddress: '0x0000000000000000000000000000000000000003', + asset: payIns[0].asset, + status: PayInStatus.ACKNOWLEDGED, + payIns, + } as SendGroup; + + return { group, payIns }; + } + + beforeEach(() => { + payInRepo = mock(); + delegationService = mock(); + strategy = new TestEvmTokenStrategy(mock(), payInRepo, delegationService); + saveSpy = jest.spyOn(payInRepo, 'save').mockImplementation(async (payIn) => payIn as CryptoInput); + }); + + it('persists Sending on every member before calling the delegation broadcast sink', async () => { + const { group, payIns } = createGroup(); + const broadcastError = new TxBroadcastError('broadcast failed'); + const transferSpy = jest.spyOn(delegationService, 'transferTokenViaDelegation').mockRejectedValue(broadcastError); + + await expect(strategy['dispatchViaDelegation'](group, SendType.FORWARD)).rejects.toBe(broadcastError); + + expect(payIns.map((payIn) => payIn.status)).toEqual([PayInStatus.SENDING, PayInStatus.SENDING]); + expect(saveSpy.mock.invocationCallOrder[1]).toBeLessThan(transferSpy.mock.invocationCallOrder[0]); + }); + + it('restores captured statuses when delegation fails before its broadcast boundary', async () => { + const { group, payIns } = createGroup(); + const preBroadcastError = new Error('gas estimation failed'); + jest.spyOn(delegationService, 'transferTokenViaDelegation').mockRejectedValue(preBroadcastError); + + await expect(strategy['dispatchViaDelegation'](group, SendType.FORWARD)).rejects.toBe(preBroadcastError); + + expect(payIns.map((payIn) => payIn.status)).toEqual([PayInStatus.ACKNOWLEDGED, PayInStatus.ACKNOWLEDGED]); + expect(payInRepo.save).toHaveBeenCalledTimes(4); + }); + + it('keeps Sending when the delegation broadcast boundary throws TxBroadcastError', async () => { + const { group, payIns } = createGroup(); + const broadcastError = new TxBroadcastError('relayer timeout'); + jest.spyOn(delegationService, 'transferTokenViaDelegation').mockRejectedValue(broadcastError); + + await expect(strategy['dispatchViaDelegation'](group, SendType.FORWARD)).rejects.toBe(broadcastError); + + expect(payIns.map((payIn) => payIn.status)).toEqual([PayInStatus.SENDING, PayInStatus.SENDING]); + expect(payInRepo.save).toHaveBeenCalledTimes(2); + }); + + it('keeps every member Sending and logs the tx hash when persistence fails after dispatch', async () => { + const { group, payIns } = createGroup(); + const txHash = '0xdelegated'; + const persistenceError = new Error('deadlock'); + jest.spyOn(delegationService, 'transferTokenViaDelegation').mockResolvedValue(txHash); + jest.spyOn(strategy as any, 'updatePayInWithSendData').mockRejectedValue(persistenceError); + const logSpy = jest.spyOn(strategy['logger'], 'error').mockImplementation(); + + await expect(strategy['dispatchViaDelegation'](group, SendType.FORWARD)).rejects.toBe(persistenceError); + + expect(payIns.map((payIn) => payIn.status)).toEqual([PayInStatus.SENDING, PayInStatus.SENDING]); + expect(payInRepo.save).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(txHash), persistenceError); + expect(logSpy.mock.calls[0][0]).toContain('1, 2'); + }); + }); + describe('isDelegationSupported wrapper', () => { it('should correctly delegate to the delegation service', () => { // This is tested by the delegation service tests diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/evm.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/evm.strategy.ts index 17eefcd6f7..00afbed9b2 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/evm.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/evm.strategy.ts @@ -1,5 +1,6 @@ import { ethers } from 'ethers'; import { Config } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { DfxLogger, LogLevel } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { @@ -195,11 +196,45 @@ export abstract class EvmStrategy extends SendStrategy { } private async dispatch(payInGroup: SendGroup, type: SendType, estimatedNativeFee: number): Promise { - const outTxId = await this.dispatchSend(payInGroup, type, estimatedNativeFee); + // Persist the in-flight marker before broadcasting: a crash after the send but before the + // transaction ID is saved must not leave the group re-selectable by the minute cron. + const previousStatuses = new Map(payInGroup.payIns.map((payIn) => [payIn.id, payIn.status])); + for (const payIn of payInGroup.payIns) { + payIn.designateSending(); + await this.payInRepo.save(payIn); + } + + let outTxId: string | undefined; + try { + outTxId = await this.dispatchSend(payInGroup, type, estimatedNativeFee); + + const updatedPayIns = await this.updatePayInsWithSendData(payInGroup, outTxId, type); + + await this.saveUpdatedPayIns(updatedPayIns); + } catch (e) { + if (e instanceof TxBroadcastError || outTxId !== undefined) { + // The broadcast boundary was reached and the transaction may be in flight. Fail closed by + // keeping SENDING; the cron escalation moves the group to SEND_UNCERTAIN for investigation. + if (outTxId !== undefined) { + this.logger.error( + `Failed to persist EVM send transaction ${outTxId} for pay-ins ${payInGroup.payIns + .map((payIn) => payIn.id) + .join(', ')}:`, + e, + ); + } + throw e; + } - const updatedPayIns = await this.updatePayInsWithSendData(payInGroup, outTxId, type); + // A plain error before a transaction ID was obtained is provably pre-broadcast. Restore each + // member's captured status so the next cron run can retry it. + for (const payIn of payInGroup.payIns) { + payIn.status = previousStatuses.get(payIn.id); + await this.payInRepo.save(payIn); + } - await this.saveUpdatedPayIns(updatedPayIns); + throw e; + } } private async updatePayInsWithSendData( diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/evm.token.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/evm.token.strategy.ts index cd9fd225ca..268afcfb86 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/evm.token.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/evm.token.strategy.ts @@ -1,4 +1,5 @@ import { Config } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Eip7702DelegationService } from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service'; import { LogLevel } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; @@ -76,14 +77,47 @@ export abstract class EvmTokenStrategy extends EvmStrategy { `Sending ${amount} ${asset.name} from ${payInGroup.sourceAddress} to ${destinationAddress} via EIP-7702 delegation`, ); - const txHash = await this.delegationService.transferTokenViaDelegation(account, asset, destinationAddress, amount); - - // Update pay-ins with transaction data (fee is paid by relayer, not deducted from amount) + // The delegation path bypasses EvmStrategy.dispatch, so it needs the same persisted crash + // barrier before its own broadcast boundary. + const previousStatuses = new Map(payInGroup.payIns.map((payIn) => [payIn.id, payIn.status])); for (const payIn of payInGroup.payIns) { - const updatedPayIn = await this.updatePayInWithSendData(payIn, type, txHash); - if (updatedPayIn) { - await this.payInRepo.save(updatedPayIn); + payIn.designateSending(); + await this.payInRepo.save(payIn); + } + + let txHash: string | undefined; + try { + txHash = await this.delegationService.transferTokenViaDelegation(account, asset, destinationAddress, amount); + + // Update pay-ins with transaction data (fee is paid by relayer, not deducted from amount) + for (const payIn of payInGroup.payIns) { + const updatedPayIn = await this.updatePayInWithSendData(payIn, type, txHash); + if (updatedPayIn) { + await this.payInRepo.save(updatedPayIn); + } } + } catch (e) { + if (e instanceof TxBroadcastError || txHash !== undefined) { + // The relayer send may have succeeded despite the error. Keep the non-reselectable marker + // and let the cron escalate it instead of risking a second delegated transfer. + if (txHash !== undefined) { + this.logger.error( + `Failed to persist delegated EVM send transaction ${txHash} for pay-ins ${payInGroup.payIns + .map((payIn) => payIn.id) + .join(', ')}:`, + e, + ); + } + throw e; + } + + // A plain error before a transaction hash was obtained is provably pre-broadcast and safe to retry. + for (const payIn of payInGroup.payIns) { + payIn.status = previousStatuses.get(payIn.id); + await this.payInRepo.save(payIn); + } + + throw e; } } From 286392235880580fafc92835a7ba669b201f51c0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:55:14 +0200 Subject: [PATCH 09/19] feat(bank): wire Bank Frick custody assets into equity, liquidity and accounting (#4252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bank): wire Bank Frick custody assets into equity, liquidity and accounting Bank Frick was active as a bank rail but had no linked custody Asset, so its cash was invisible to equity/FinanceLog and the safety kill-switch, its balance was never refreshed by liquidity management, its bank_tx booked to SUSPENSE, and pending-input-amount matching returned zero for Frick credits. Add a Blockchain.FRICK member and two dedicated Frick custody Assets (EUR/CHF), linked to the active Frick bank rows via bank.assetId, and register observe-only liquidity-management rules (context 'Bank Frick') so the existing per-bank balance adapter refreshes Frick balances without ever triggering a fund-moving action. Add a Frick arm to every blockchain switch/exhaustive map that previously listed only Olkypay/Yapeal (pendingInputAmount, blockchainToBankName, BANK_BLOCKCHAINS, the EUR-bank log aggregation, and the exhaustive Blockchain maps). The data migration is prod-only and idempotent (local/dev obtain the Frick custody assets from the seed). It fails loud if a required price source is missing or if an active Frick bank row is left unlinked. Customer-facing deposit routing and Frick's fallback sendPriority are unchanged. * fix(bank): capture Bank Frick in the EUR-bank reconciliation and refine migration docs Bank Frick was added to the EUR-bank asset filter but not to the EUR-bank IBAN set that gates the Scrypt reconciliation, so a Frick<->Scrypt EUR transfer was zeroed on the Frick asset row and never picked up on the aggregate row — it silently vanished from the pending computation. Add the active Frick EUR bank's IBAN to that set and cover it with a regression test. Also cross-reference the operations runbook in the new migration's docblock (this migration performs the custody-asset link retroactively) and scope the idempotency note to up(). --- ...1784500000000-AddBankFrickCustodyAssets.js | 237 ++++++++++++ migration/seed/asset.csv | 2 + .../shared/enums/blockchain.enum.ts | 1 + .../blockchain/shared/util/blockchain.util.ts | 2 + .../services/__tests__/exchange.test.ts | 1 + .../exchange/services/binance.service.ts | 1 + .../exchange/services/bitstamp.service.ts | 1 + .../exchange/services/kraken.service.ts | 1 + .../exchange/services/kucoin.service.ts | 1 + .../exchange/services/mexc.service.ts | 1 + .../exchange/services/xt.service.ts | 1 + .../ledger-reconciliation.service.spec.ts | 8 + .../services/ledger-reconciliation.service.ts | 1 + .../__tests__/buy-crypto.entity.spec.ts | 39 ++ .../process/entities/buy-crypto.entity.ts | 1 + .../reward/services/ref-reward.service.ts | 1 + .../bank-tx-repeat/bank-tx-repeat.entity.ts | 1 + .../bank-tx-return/bank-tx-return.entity.ts | 1 + .../bank-tx/__tests__/bank-tx.entity.spec.ts | 41 +- .../bank-tx/entities/bank-tx.entity.ts | 1 + ...ank-frick-custody-assets.migration.spec.ts | 360 ++++++++++++++++++ .../bank/bank/__tests__/bank.service.spec.ts | 23 ++ .../supporting/bank/bank/bank.service.ts | 2 + .../dashboard-reconciliation.service.spec.ts | 6 + .../dashboard-reconciliation.service.ts | 1 + .../log/__tests__/log-job.service.spec.ts | 81 +++- .../supporting/log/log-job.service.ts | 7 +- 27 files changed, 818 insertions(+), 5 deletions(-) create mode 100644 migration/1784500000000-AddBankFrickCustodyAssets.js create mode 100644 src/subdomains/supporting/bank/bank/__tests__/add-bank-frick-custody-assets.migration.spec.ts diff --git a/migration/1784500000000-AddBankFrickCustodyAssets.js b/migration/1784500000000-AddBankFrickCustodyAssets.js new file mode 100644 index 0000000000..6a5f0c7496 --- /dev/null +++ b/migration/1784500000000-AddBankFrickCustodyAssets.js @@ -0,0 +1,237 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * PROD-ONLY data migration that wires Bank Frick into equity / liquidity / accounting. + * + * Creates two Custody assets (Frick/EUR, Frick/CHF), links them to active Bank Frick bank rows + * via bank.assetId (NULL → value fill only), and registers observe-only LiquidityManagementRules + * so balance refresh runs for Frick without ever triggering fund-moving actions. + * + * Guarded to `ENVIRONMENT === 'prd'` (same rationale as ActivateBankFrick): an unguarded + * status='Active' Frick LM rule would make the EVERY_MINUTE liquidity cron call + * frickService.getBalances() in every non-prod environment (no Frick credentials there) and + * log an error every minute. On dev/loc/CI this migration is a complete no-op; local/dev + * obtain the Frick custody assets from migration/seed/asset.csv instead (ids 411/412), + * unlinked from any bank row and without an LM rule — matching the dormant seed Bank Frick + * rows (receive=FALSE, send=FALSE). + * + * Per `docs/bank-frick-operations.md` §3.3 step 1, a Frick bank row must be linked to its + * custody/liquidity asset - and that asset's balance refresh verified - BEFORE `send=true` is set. + * The already-merged `ActivateBankFrick1784400000000` set `receive=TRUE, send=TRUE` directly + * without performing that link. Because this migration's timestamp is strictly later, it always + * runs after `ActivateBankFrick` and is the one that retroactively performs the runbook §3.3 + * step-1 link for the rows that migration activated. + * + * up() is fully idempotent and additive-only on prod: never overwrites a non-null assetId, + * never deletes pre-existing rows. down() is not idempotent in that sense - see below. + * + * up(): + * 1. prod guard (no-op elsewhere) + * 2. lock_timeout + * 3. fail-loud price-source guard (Yapeal/* primary, Olkypay/* fallback) per currency + * 4. insert Frick/EUR + Frick/CHF custody assets with COALESCE-inlined price columns + * (idempotent per uniqueName) + * 5. link active Bank Frick bank rows (receive+send, assetId IS NULL) via uniqueName subquery + * 6. fail-loud post-condition: every active Frick bank row must now have a non-null assetId + * 7. insert observe-only LM rules (context='Bank Frick', Active, targetAsset via subquery, + * everything else NULL) so LiquidityManagementService refreshes balances without ever + * calling executeRule + * + * down() reverses in FK-safe order (rules → unlink bank → delete assets), prod-guarded the + * same way. Asset DELETE fails loud (FK violation) if a liquidity_balance / ledger row already + * references them — intentional: rolling back a used wiring is an Ops procedure, not a plain + * migration revert. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddBankFrickCustodyAssets1784500000000 { + name = 'AddBankFrickCustodyAssets1784500000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // Active Frick LM rules must NEVER run on dev/loc/CI — there the Frick custody assets come + // from migration/seed/asset.csv (unlinked) and the Bank Frick registry is dormant. Returning + // early still records the migration as executed, which is the intended no-op on lower + // environments. + if (process.env.ENVIRONMENT !== 'prd') return; + + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + + // --- Frick/EUR --- + const eurPriceSources = ( + await queryRunner.query( + `SELECT COUNT(*)::int AS n FROM "asset" WHERE "uniqueName" IN ('Yapeal/EUR', 'Olkypay/EUR')`, + ) + ).at(0); + if (eurPriceSources.n === 0) { + throw new Error( + 'Cannot create Frick/EUR custody asset: no price source found (tried Yapeal/EUR and Olkypay/EUR)', + ); + } + + const frickEurExisting = (await queryRunner.query(`SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/EUR'`)).at( + 0, + ); + if (!frickEurExisting) { + await queryRunner.query(` + INSERT INTO "asset" + ("name", "uniqueName", "type", "blockchain", "category", "dexName", "financialType", + "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon", + "priceRuleId", "approxPriceChf", "approxPriceEur", "approxPriceUsd") + VALUES + ('EUR', 'Frick/EUR', 'Custody', 'Frick', 'Private', 'EUR', 'EUR', + false, false, false, false, false, false, + false, false, true, false, false, false, + COALESCE( + (SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Yapeal/EUR'), + (SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Olkypay/EUR') + ), + COALESCE( + (SELECT "approxPriceChf" FROM "asset" WHERE "uniqueName" = 'Yapeal/EUR'), + (SELECT "approxPriceChf" FROM "asset" WHERE "uniqueName" = 'Olkypay/EUR') + ), + COALESCE( + (SELECT "approxPriceEur" FROM "asset" WHERE "uniqueName" = 'Yapeal/EUR'), + (SELECT "approxPriceEur" FROM "asset" WHERE "uniqueName" = 'Olkypay/EUR') + ), + COALESCE( + (SELECT "approxPriceUsd" FROM "asset" WHERE "uniqueName" = 'Yapeal/EUR'), + (SELECT "approxPriceUsd" FROM "asset" WHERE "uniqueName" = 'Olkypay/EUR') + )) + `); + } + + // --- Frick/CHF --- + const chfPriceSources = ( + await queryRunner.query( + `SELECT COUNT(*)::int AS n FROM "asset" WHERE "uniqueName" IN ('Yapeal/CHF', 'Olkypay/CHF')`, + ) + ).at(0); + if (chfPriceSources.n === 0) { + throw new Error( + 'Cannot create Frick/CHF custody asset: no price source found (tried Yapeal/CHF and Olkypay/CHF)', + ); + } + + const frickChfExisting = (await queryRunner.query(`SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/CHF'`)).at( + 0, + ); + if (!frickChfExisting) { + await queryRunner.query(` + INSERT INTO "asset" + ("name", "uniqueName", "type", "blockchain", "category", "dexName", "financialType", + "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon", + "priceRuleId", "approxPriceChf", "approxPriceEur", "approxPriceUsd") + VALUES + ('CHF', 'Frick/CHF', 'Custody', 'Frick', 'Private', 'CHF', 'CHF', + false, false, false, false, false, false, + false, false, true, false, false, false, + COALESCE( + (SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Yapeal/CHF'), + (SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Olkypay/CHF') + ), + COALESCE( + (SELECT "approxPriceChf" FROM "asset" WHERE "uniqueName" = 'Yapeal/CHF'), + (SELECT "approxPriceChf" FROM "asset" WHERE "uniqueName" = 'Olkypay/CHF') + ), + COALESCE( + (SELECT "approxPriceEur" FROM "asset" WHERE "uniqueName" = 'Yapeal/CHF'), + (SELECT "approxPriceEur" FROM "asset" WHERE "uniqueName" = 'Olkypay/CHF') + ), + COALESCE( + (SELECT "approxPriceUsd" FROM "asset" WHERE "uniqueName" = 'Yapeal/CHF'), + (SELECT "approxPriceUsd" FROM "asset" WHERE "uniqueName" = 'Olkypay/CHF') + )) + `); + } + + // Pure NULL→value fill on active Frick rows only; asset resolved by stable uniqueName. + await queryRunner.query(` + UPDATE "bank" SET "assetId" = (SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/EUR') + WHERE "name" = 'Bank Frick' AND "currency" = 'EUR' AND "receive" = true AND "send" = true AND "assetId" IS NULL + `); + await queryRunner.query(` + UPDATE "bank" SET "assetId" = (SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/CHF') + WHERE "name" = 'Bank Frick' AND "currency" = 'CHF' AND "receive" = true AND "send" = true AND "assetId" IS NULL + `); + + // Fail-loud post-condition: every active Frick bank row must be linked. Vacuously true when no + // active Frick rows exist (e.g. schema-only fixture / pre-activation). + const unlinked = await queryRunner.query( + `SELECT "id", "currency" FROM "bank" + WHERE "name" = 'Bank Frick' AND "receive" = true AND "send" = true AND "assetId" IS NULL`, + ); + if (unlinked.length > 0) { + throw new Error('Bank Frick custody-asset wiring incomplete'); + } + + // Observe-only LM rules: minimal/maximal/actions all NULL so LiquidityManagementRule.verify() + // always returns action:null. status=Active so checkLiquidityBalances still refreshes the balance. + // targetFiatId deliberately omitted (NULL) — rule targets the Custody asset only. + const eurRuleExisting = ( + await queryRunner.query(` + SELECT lmr."id" FROM "liquidity_management_rule" lmr + JOIN "asset" a ON a."id" = lmr."targetAssetId" + WHERE lmr."context" = 'Bank Frick' AND a."uniqueName" = 'Frick/EUR' AND lmr."targetFiatId" IS NULL + `) + ).at(0); + if (!eurRuleExisting) { + await queryRunner.query(` + INSERT INTO "liquidity_management_rule" ("context", "status", "targetAssetId") + VALUES ('Bank Frick', 'Active', (SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/EUR')) + `); + } + + const chfRuleExisting = ( + await queryRunner.query(` + SELECT lmr."id" FROM "liquidity_management_rule" lmr + JOIN "asset" a ON a."id" = lmr."targetAssetId" + WHERE lmr."context" = 'Bank Frick' AND a."uniqueName" = 'Frick/CHF' AND lmr."targetFiatId" IS NULL + `) + ).at(0); + if (!chfRuleExisting) { + await queryRunner.query(` + INSERT INTO "liquidity_management_rule" ("context", "status", "targetAssetId") + VALUES ('Bank Frick', 'Active', (SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/CHF')) + `); + } + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + if (process.env.ENVIRONMENT !== 'prd') return; + + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + + // Rules first (FK-safe), then unlink bank rows, then delete assets. + await queryRunner.query(` + DELETE FROM "liquidity_management_rule" + WHERE "context" = 'Bank Frick' + AND "targetAssetId" IN ( + SELECT "id" FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF') + ) + `); + + await queryRunner.query(` + UPDATE "bank" SET "assetId" = NULL + WHERE "assetId" IN ( + SELECT "id" FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF') + ) + `); + + // This DELETE will fail loud (FK violation) if a liquidity_balance / ledger row already + // references these assets by the time someone rolls this back — that is intentional: rolling + // back a used wiring is an Ops procedure, not a plain migration revert. + await queryRunner.query(`DELETE FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF')`); + } +}; diff --git a/migration/seed/asset.csv b/migration/seed/asset.csv index 3d0efe4d19..09b49641de 100644 --- a/migration/seed/asset.csv +++ b/migration/seed/asset.csv @@ -226,3 +226,5 @@ id,name,type,buyable,sellable,chainId,sellCommand,dexName,category,blockchain,un 111,ETH,Coin,TRUE,TRUE,0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,,ETH,Public,Ethereum,Ethereum/ETH,Ether,FALSE,1,2922.079246,FALSE,6,2304.065646,FALSE,FALSE,FALSE,FALSE,Other,18,TRUE,0,0,2480.82045,TRUE 409,FIRO,Coin,TRUE,TRUE,,,FIRO,Public,Firo,Firo/FIRO,,FALSE,,1.5,FALSE,,1.35,FALSE,FALSE,FALSE,FALSE,Other,8,FALSE,0,0,1.4,TRUE 410,EUR,Custody,FALSE,FALSE,,,EUR,Private,OlkyFrozen,OlkyFrozen/EUR,,FALSE,,1.17786809,FALSE,39,0.9287514723,FALSE,FALSE,FALSE,FALSE,EUR,,FALSE,0,0,1,TRUE +411,EUR,Custody,FALSE,FALSE,,,EUR,Private,Frick,Frick/EUR,,FALSE,,1.17786809,FALSE,39,0.9287514723,FALSE,FALSE,FALSE,FALSE,EUR,,FALSE,0,0,1,TRUE +412,CHF,Custody,FALSE,FALSE,,,CHF,Private,Frick,Frick/CHF,,FALSE,,1.268227427,FALSE,37,1,FALSE,FALSE,FALSE,FALSE,CHF,,FALSE,0,0,1.076714309,TRUE diff --git a/src/integration/blockchain/shared/enums/blockchain.enum.ts b/src/integration/blockchain/shared/enums/blockchain.enum.ts index e804df98e8..2a3ea291d9 100644 --- a/src/integration/blockchain/shared/enums/blockchain.enum.ts +++ b/src/integration/blockchain/shared/enums/blockchain.enum.ts @@ -44,4 +44,5 @@ export enum Blockchain { CHECKOUT = 'Checkout', SUMIXX = 'Sumixx', YAPEAL = 'Yapeal', + FRICK = 'Frick', } diff --git a/src/integration/blockchain/shared/util/blockchain.util.ts b/src/integration/blockchain/shared/util/blockchain.util.ts index 07f5958abb..7e27dc31fd 100644 --- a/src/integration/blockchain/shared/util/blockchain.util.ts +++ b/src/integration/blockchain/shared/util/blockchain.util.ts @@ -126,6 +126,7 @@ const BlockchainExplorerUrls: { [b in Blockchain]: string } = { [Blockchain.CHECKOUT]: undefined, [Blockchain.SUMIXX]: undefined, [Blockchain.YAPEAL]: undefined, + [Blockchain.FRICK]: undefined, }; const TxPaths: { [b in Blockchain]: string } = { @@ -168,6 +169,7 @@ const TxPaths: { [b in Blockchain]: string } = { [Blockchain.CHECKOUT]: undefined, [Blockchain.SUMIXX]: undefined, [Blockchain.YAPEAL]: undefined, + [Blockchain.FRICK]: undefined, }; function assetPaths(asset: Asset): string | undefined { diff --git a/src/integration/exchange/services/__tests__/exchange.test.ts b/src/integration/exchange/services/__tests__/exchange.test.ts index c3562f9593..983695d9f9 100644 --- a/src/integration/exchange/services/__tests__/exchange.test.ts +++ b/src/integration/exchange/services/__tests__/exchange.test.ts @@ -52,5 +52,6 @@ export class TestExchangeService extends ExchangeService { Checkout: undefined, Sumixx: undefined, Yapeal: undefined, + Frick: undefined, }; } diff --git a/src/integration/exchange/services/binance.service.ts b/src/integration/exchange/services/binance.service.ts index 67281ae2fa..d8c25f8e5d 100644 --- a/src/integration/exchange/services/binance.service.ts +++ b/src/integration/exchange/services/binance.service.ts @@ -49,6 +49,7 @@ export class BinanceService extends ExchangeService { Checkout: undefined, Sumixx: undefined, Yapeal: undefined, + Frick: undefined, }; constructor() { diff --git a/src/integration/exchange/services/bitstamp.service.ts b/src/integration/exchange/services/bitstamp.service.ts index 4b8540dbec..0caf307e2b 100644 --- a/src/integration/exchange/services/bitstamp.service.ts +++ b/src/integration/exchange/services/bitstamp.service.ts @@ -49,6 +49,7 @@ export class BitstampService extends ExchangeService { Checkout: undefined, Sumixx: undefined, Yapeal: undefined, + Frick: undefined, }; constructor() { diff --git a/src/integration/exchange/services/kraken.service.ts b/src/integration/exchange/services/kraken.service.ts index eae73d11ad..cacc6bfad2 100644 --- a/src/integration/exchange/services/kraken.service.ts +++ b/src/integration/exchange/services/kraken.service.ts @@ -56,6 +56,7 @@ export class KrakenService extends ExchangeService { Checkout: undefined, Sumixx: undefined, Yapeal: undefined, + Frick: undefined, }; @Inject() private readonly settingService: SettingService; diff --git a/src/integration/exchange/services/kucoin.service.ts b/src/integration/exchange/services/kucoin.service.ts index 5feb06255c..e20775a888 100644 --- a/src/integration/exchange/services/kucoin.service.ts +++ b/src/integration/exchange/services/kucoin.service.ts @@ -49,6 +49,7 @@ export class KucoinService extends ExchangeService { Checkout: undefined, Sumixx: undefined, Yapeal: undefined, + Frick: undefined, }; constructor() { diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index 0f5f74a17d..127a59782d 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -65,6 +65,7 @@ export class MexcService extends ExchangeService { Checkout: undefined, Sumixx: undefined, Yapeal: undefined, + Frick: undefined, }; constructor(private readonly http: HttpService) { diff --git a/src/integration/exchange/services/xt.service.ts b/src/integration/exchange/services/xt.service.ts index 5b400e4e0e..511aeecbe8 100644 --- a/src/integration/exchange/services/xt.service.ts +++ b/src/integration/exchange/services/xt.service.ts @@ -61,6 +61,7 @@ export class XtService extends ExchangeService { Checkout: undefined, Sumixx: undefined, Yapeal: undefined, + Frick: undefined, }; constructor() { diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts index 1d2664bdea..f001ea16df 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-reconciliation.service.spec.ts @@ -741,6 +741,14 @@ describe('LedgerReconciliationService', () => { expect(result.status).toBe(FeedStatus.FRESH); // 50h < 96h }); + it('classifies a Frick custody asset without a bank relation as BANK_ACTIVE (96h threshold)', () => { + const account = assetAccount(11, { blockchain: Blockchain.FRICK }); + const result = service.classifyFeed(balance(11, 100, Util.hoursBefore(50, now)), account, now); + expect(result.custodyClass).toBe(CustodyClass.BANK_ACTIVE); + expect(result.thresholdHours).toBe(96); + expect(result.status).toBe(FeedStatus.FRESH); // 50h < 96h + }); + it('classifies a no-asset account as ON_CHAIN_INACTIVE (24h default)', () => { const account = createCustomLedgerAccount({ id: 1, name: 'x', type: AccountType.ASSET, assetId: 1 } as any); const result = service.classifyFeed(balance(1, 100, Util.hoursBefore(2, now)), account, now); diff --git a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts index 2fff5327bf..5e758c226f 100644 --- a/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts +++ b/src/subdomains/core/accounting/services/ledger-reconciliation.service.ts @@ -46,6 +46,7 @@ const BANK_BLOCKCHAINS: Blockchain[] = [ Blockchain.CHECKOUT, Blockchain.SUMIXX, Blockchain.YAPEAL, + Blockchain.FRICK, ]; // §7.1 custody classification → staleness threshold (hours) 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 1903e56c90..4e814c031a 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 @@ -1,10 +1,15 @@ import { Test } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; 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 { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; +import { createCustomBankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; import { Price, PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { createCustomBuyCrypto, createDefaultBuyCrypto } from '../__mocks__/buy-crypto.entity.mock'; import { BuyCrypto } from '../buy-crypto.entity'; @@ -599,6 +604,40 @@ describe('BuyCrypto', () => { }); }); + describe('#pendingInputAmount', () => { + const frickIban = 'LI75088110105923K000E'; + + beforeEach(() => { + (BankService as unknown as { ibanCache: Map }).ibanCache.clear(); + (BankService as unknown as { ibanCache: Map }).ibanCache.set( + `${IbanBankName.FRICK}-EUR`, + frickIban, + ); + }); + + it('returns inputReferenceAmount for a matching Frick custody asset when output is not yet set', () => { + const entity = createCustomBuyCrypto({ + outputAmount: undefined, + inputReferenceAmount: 150, + bankTx: createCustomBankTx({ accountIban: frickIban }), + }); + const asset = createCustomAsset({ blockchain: Blockchain.FRICK, dexName: 'EUR' }); + + expect(entity.pendingInputAmount(asset)).toBe(150); + }); + + it('returns 0 for a Frick asset when the bankTx IBAN does not match', () => { + const entity = createCustomBuyCrypto({ + outputAmount: undefined, + inputReferenceAmount: 150, + bankTx: createCustomBankTx({ accountIban: 'OTHER-IBAN' }), + }); + const asset = createCustomAsset({ blockchain: Blockchain.FRICK, dexName: 'EUR' }); + + expect(entity.pendingInputAmount(asset)).toBe(0); + }); + }); + describe('#amlCheckAndFillUp Scorechain gate', () => { afterEach(() => jest.restoreAllMocks()); 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 58c72a1181..098e9e9e8e 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 @@ -814,6 +814,7 @@ export class BuyCrypto extends IEntity { case Blockchain.MAERKI_BAUMANN: case Blockchain.OLKYPAY: case Blockchain.YAPEAL: + case Blockchain.FRICK: return BankService.isBankMatching(asset, this.bankTx?.accountIban) ? this.inputReferenceAmount : 0; case Blockchain.CHECKOUT: diff --git a/src/subdomains/core/referral/reward/services/ref-reward.service.ts b/src/subdomains/core/referral/reward/services/ref-reward.service.ts index fabf46f5d9..0b7dd8540f 100644 --- a/src/subdomains/core/referral/reward/services/ref-reward.service.ts +++ b/src/subdomains/core/referral/reward/services/ref-reward.service.ts @@ -61,6 +61,7 @@ const PayoutLimits: { [k in Blockchain]: number } = { [Blockchain.CHECKOUT]: undefined, [Blockchain.SUMIXX]: undefined, [Blockchain.YAPEAL]: undefined, + [Blockchain.FRICK]: undefined, }; @Injectable() diff --git a/src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity.ts b/src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity.ts index e279777865..2d6ec36ef3 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.entity.ts @@ -74,6 +74,7 @@ export class BankTxRepeat extends IEntity { case Blockchain.MAERKI_BAUMANN: case Blockchain.OLKYPAY: case Blockchain.YAPEAL: + case Blockchain.FRICK: return BankService.isBankMatching(asset, this.bankTx.accountIban) ? this.bankTx.amount : 0; default: diff --git a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity.ts b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity.ts index 98a11e7bf6..a8dd64826b 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.entity.ts @@ -153,6 +153,7 @@ export class BankTxReturn extends IEntity { case Blockchain.MAERKI_BAUMANN: case Blockchain.OLKYPAY: case Blockchain.YAPEAL: + case Blockchain.FRICK: return BankService.isBankMatching(asset, this.bankTx.accountIban) ? this.bankTx.amount : 0; default: diff --git a/src/subdomains/supporting/bank-tx/bank-tx/__tests__/bank-tx.entity.spec.ts b/src/subdomains/supporting/bank-tx/bank-tx/__tests__/bank-tx.entity.spec.ts index 3ace15ef37..59d6ea179c 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/__tests__/bank-tx.entity.spec.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/__tests__/bank-tx.entity.spec.ts @@ -1,9 +1,48 @@ +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; import { createCustomSpecialExternalAccount } from 'src/subdomains/supporting/payment/__mocks__/special-external-account.entity.mock'; -import { BankTx } from '../entities/bank-tx.entity'; +import { createCustomBankTx } from '../__mocks__/bank-tx.entity.mock'; +import { BankTx, BankTxType } from '../entities/bank-tx.entity'; describe('BankTx', () => { const multiAccount = createCustomSpecialExternalAccount({ value: 'MULTI-ACCOUNT-IBAN', name: 'MULTI-ACCOUNT-IBAN' }); + describe('#pendingInputAmount(...)', () => { + const frickIban = 'LI75088110105923K000E'; + + beforeEach(() => { + (BankService as unknown as { ibanCache: Map }).ibanCache.clear(); + (BankService as unknown as { ibanCache: Map }).ibanCache.set( + `${IbanBankName.FRICK}-EUR`, + frickIban, + ); + }); + + it('returns the credit amount for a matching Frick custody asset', () => { + const entity = createCustomBankTx({ + type: BankTxType.PENDING, + amount: 250, + accountIban: frickIban, + }); + const asset = createCustomAsset({ blockchain: Blockchain.FRICK, dexName: 'EUR' }); + + expect(entity.pendingInputAmount(asset)).toBe(250); + }); + + it('returns 0 for a Frick asset when the account IBAN does not match', () => { + const entity = createCustomBankTx({ + type: BankTxType.PENDING, + amount: 250, + accountIban: 'OTHER-IBAN', + }); + const asset = createCustomAsset({ blockchain: Blockchain.FRICK, dexName: 'EUR' }); + + expect(entity.pendingInputAmount(asset)).toBe(0); + }); + }); + describe('#senderAccount(...)', () => { it('should return the IBAN', () => { const entity = Object.assign(new BankTx(), { iban: 'RANDOM-IBAN' }); diff --git a/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts b/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts index 47cd168325..5171abe998 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts @@ -372,6 +372,7 @@ export class BankTx extends IEntity { case Blockchain.MAERKI_BAUMANN: case Blockchain.OLKYPAY: case Blockchain.YAPEAL: + case Blockchain.FRICK: return BankService.isBankMatching(asset, this.accountIban) ? this.amount : 0; default: diff --git a/src/subdomains/supporting/bank/bank/__tests__/add-bank-frick-custody-assets.migration.spec.ts b/src/subdomains/supporting/bank/bank/__tests__/add-bank-frick-custody-assets.migration.spec.ts new file mode 100644 index 0000000000..46d5f5ff56 --- /dev/null +++ b/src/subdomains/supporting/bank/bank/__tests__/add-bank-frick-custody-assets.migration.spec.ts @@ -0,0 +1,360 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'frick_custody_assets_spec'; + +let AddBankFrickCustodyAssets: new () => { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +/** Prod-gate the migration under test; restore the original ENVIRONMENT afterward. */ +function withPrdEnv() { + const originalEnv = process.env.ENVIRONMENT; + beforeEach(() => { + process.env.ENVIRONMENT = 'prd'; + }); + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnv; + } + }); +} + +describe('AddBankFrickCustodyAssets migration (SQL content)', () => { + withPrdEnv(); + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddBankFrickCustodyAssets = require('../../../../../../migration/1784500000000-AddBankFrickCustodyAssets'); + }); + + it('creates observe-only LM rules (no minimal/maximal/action FKs) and never overwrites non-null assetId', async () => { + const migration = new AddBankFrickCustodyAssets(); + const queryRunner = { + query: jest.fn(async (sql: string) => { + const s = sql.toLowerCase(); + // price-source COUNT guard — report sources present + if (s.includes('count(*)') && s.includes('yapeal/eur')) { + return [{ n: 1 }]; + } + if (s.includes('count(*)') && s.includes('yapeal/chf')) { + return [{ n: 1 }]; + } + // idempotency: assets do not exist yet + if (s.includes('from "asset"') && s.includes(`'frick/eur'`) && !s.includes('insert')) { + return []; + } + if (s.includes('from "asset"') && s.includes(`'frick/chf'`) && !s.includes('insert')) { + return []; + } + // unlinked check passes + if (s.includes('from "bank"') && s.includes('assetid" is null')) { + return []; + } + // LM rule does not exist yet + if (s.includes('from "liquidity_management_rule"')) { + return []; + } + return []; + }), + }; + + await migration.up(queryRunner as unknown as QueryRunner); + + const calls = queryRunner.query.mock.calls as [string, unknown[]?][]; + const sql = calls.map(([statement]) => statement).join('\n'); + + // Every query must be single-argument (no bound parameter arrays) + for (const call of calls) { + expect(call).toHaveLength(1); + } + + expect(sql).toContain(`SET LOCAL lock_timeout = '5s'`); + // uniqueName / currency are inlined SQL literals + expect(sql).toContain(`'Frick/EUR'`); + expect(sql).toContain(`'Frick/CHF'`); + expect(sql).toContain(`COALESCE(`); + expect(sql).toContain(`'Yapeal/EUR'`); + expect(sql).toContain(`'Olkypay/EUR'`); + expect(sql).toContain(`INSERT INTO "liquidity_management_rule" ("context", "status", "targetAssetId")`); + expect(sql).toContain(`'Bank Frick'`); + expect(sql).toContain(`'Active'`); + expect(sql).toContain(`(SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/EUR')`); + // Observe-only: INSERT must not set minimal/maximal/action columns + expect(sql).not.toMatch(/INSERT INTO "liquidity_management_rule"[^;]*"minimal"/i); + expect(sql).not.toMatch(/INSERT INTO "liquidity_management_rule"[^;]*"maximal"/i); + expect(sql).not.toMatch(/INSERT INTO "liquidity_management_rule"[^;]*deficitStartActionId/i); + // Bank link is NULL-only fill + expect(sql).toContain(`"assetId" IS NULL`); + }); + + it('throws when neither Yapeal nor Olkypay price source exists', async () => { + const migration = new AddBankFrickCustodyAssets(); + const queryRunner = { + query: jest.fn(async (sql: string) => { + if (sql.includes('lock_timeout')) return undefined; + if (sql.toLowerCase().includes('count(*)')) return [{ n: 0 }]; + return []; + }), + }; + + await expect(migration.up(queryRunner as unknown as QueryRunner)).rejects.toThrow( + /no price source found.*Yapeal\/EUR.*Olkypay\/EUR/, + ); + }); +}); + +describeDb('AddBankFrickCustodyAssets migration (real Postgres)', () => { + withPrdEnv(); + + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddBankFrickCustodyAssets = require('../../../../../../migration/1784500000000-AddBankFrickCustodyAssets'); + dataSource = new DataSource({ type: 'postgres', url: PG_URL }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.query(`CREATE SCHEMA "${SCHEMA}"`); + await queryRunner.query(`SET search_path TO "${SCHEMA}"`); + + // Minimal fixture tables — only the columns the migration touches. + await queryRunner.query(` + CREATE TABLE "asset" ( + "id" SERIAL PRIMARY KEY, + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "created" TIMESTAMP NOT NULL DEFAULT now(), + "name" varchar(256) NOT NULL, + "uniqueName" varchar(256) NOT NULL, + "type" varchar(256) NOT NULL, + "blockchain" varchar(256) NOT NULL, + "category" varchar(256) NOT NULL DEFAULT 'Public', + "dexName" varchar(256), + "financialType" varchar(256), + "buyable" boolean NOT NULL DEFAULT true, + "sellable" boolean NOT NULL DEFAULT true, + "cardBuyable" boolean NOT NULL DEFAULT true, + "cardSellable" boolean NOT NULL DEFAULT true, + "instantBuyable" boolean NOT NULL DEFAULT true, + "instantSellable" boolean NOT NULL DEFAULT true, + "paymentEnabled" boolean NOT NULL DEFAULT false, + "refEnabled" boolean NOT NULL DEFAULT false, + "refundEnabled" boolean NOT NULL DEFAULT true, + "ikna" boolean NOT NULL DEFAULT false, + "personalIbanEnabled" boolean NOT NULL DEFAULT false, + "comingSoon" boolean NOT NULL DEFAULT false, + "priceRuleId" integer, + "approxPriceChf" double precision, + "approxPriceEur" double precision, + "approxPriceUsd" double precision + ) + `); + await queryRunner.query(` + CREATE TABLE "bank" ( + "id" SERIAL PRIMARY KEY, + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "created" TIMESTAMP NOT NULL DEFAULT now(), + "name" varchar(256) NOT NULL, + "iban" varchar(256) NOT NULL, + "bic" varchar(256) NOT NULL, + "currency" varchar(256) NOT NULL, + "receive" boolean NOT NULL DEFAULT true, + "send" boolean NOT NULL DEFAULT true, + "sctInst" boolean NOT NULL DEFAULT false, + "amlEnabled" boolean NOT NULL DEFAULT true, + "assetId" integer UNIQUE + ) + `); + await queryRunner.query(` + CREATE TABLE "liquidity_management_rule" ( + "id" SERIAL PRIMARY KEY, + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "created" TIMESTAMP NOT NULL DEFAULT now(), + "context" varchar(256), + "status" varchar(256), + "minimal" double precision, + "optimal" double precision, + "maximal" double precision, + "limit" double precision, + "reactivationTime" integer, + "delayActivation" boolean NOT NULL DEFAULT true, + "sendNotifications" boolean NOT NULL DEFAULT true, + "targetAssetId" integer, + "targetFiatId" integer, + "deficitStartActionId" integer, + "redundancyStartActionId" integer + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_245ccaa266891c0346b2ddb62f" + ON "liquidity_management_rule" ("context", "targetAssetId", "targetFiatId") + `); + + // Price-source fixtures (Yapeal EUR/CHF). + await queryRunner.query(` + INSERT INTO "asset" + ("name", "uniqueName", "type", "blockchain", "category", "dexName", "financialType", + "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon", + "priceRuleId", "approxPriceChf", "approxPriceEur", "approxPriceUsd") + VALUES + ('EUR', 'Yapeal/EUR', 'Custody', 'Yapeal', 'Private', 'EUR', 'EUR', + false, false, false, false, false, false, false, false, true, false, false, false, + 39, 0.9287514723, 1, 1.17786809), + ('CHF', 'Yapeal/CHF', 'Custody', 'Yapeal', 'Private', 'CHF', 'CHF', + false, false, false, false, false, false, false, false, true, false, false, false, + 37, 1, 1.076714309, 1.268227427) + `); + + // Active Bank Frick EUR/CHF pair with assetId IS NULL (prod-activated shape). + await queryRunner.query(` + INSERT INTO "bank" ("name", "iban", "bic", "currency", "receive", "send", "assetId") + VALUES + ('Bank Frick', 'LI75088110105923K000E', 'BFRILI22', 'EUR', true, true, NULL), + ('Bank Frick', 'LI32088110105923K000C', 'BFRILI22', 'CHF', true, true, NULL) + `); + }); + + afterEach(async () => { + if (queryRunner.isTransactionActive) await queryRunner.rollbackTransaction(); + await queryRunner.query(`SET search_path TO public`); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.release(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + it('creates Frick assets with copied prices, links banks, and inserts observe-only LM rules', async () => { + const migration = new AddBankFrickCustodyAssets(); + await migration.up(queryRunner); + + const assets = await queryRunner.query( + `SELECT "uniqueName", "blockchain", "dexName", "financialType", "type", "priceRuleId", + "approxPriceChf", "approxPriceEur", "approxPriceUsd", "buyable", "sellable", "refundEnabled" + FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF') ORDER BY "uniqueName"`, + ); + expect(assets).toHaveLength(2); + expect(assets[0]).toMatchObject({ + uniqueName: 'Frick/CHF', + blockchain: 'Frick', + dexName: 'CHF', + financialType: 'CHF', + type: 'Custody', + priceRuleId: 37, + approxPriceChf: 1, + approxPriceEur: 1.076714309, + approxPriceUsd: 1.268227427, + buyable: false, + sellable: false, + refundEnabled: true, + }); + expect(assets[1]).toMatchObject({ + uniqueName: 'Frick/EUR', + blockchain: 'Frick', + dexName: 'EUR', + financialType: 'EUR', + type: 'Custody', + priceRuleId: 39, + approxPriceChf: 0.9287514723, + approxPriceEur: 1, + approxPriceUsd: 1.17786809, + buyable: false, + sellable: false, + refundEnabled: true, + }); + + const frickEurId = (await queryRunner.query(`SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/EUR'`))[0] + .id as number; + const frickChfId = (await queryRunner.query(`SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/CHF'`))[0] + .id as number; + + const banks = await queryRunner.query( + `SELECT "currency", "assetId" FROM "bank" WHERE "name" = 'Bank Frick' ORDER BY "currency"`, + ); + expect(banks).toEqual([ + { currency: 'CHF', assetId: frickChfId }, + { currency: 'EUR', assetId: frickEurId }, + ]); + + const rules = await queryRunner.query( + `SELECT "context", "status", "targetAssetId", "targetFiatId", "minimal", "maximal", + "deficitStartActionId", "redundancyStartActionId" + FROM "liquidity_management_rule" WHERE "context" = 'Bank Frick' ORDER BY "targetAssetId"`, + ); + expect(rules).toHaveLength(2); + for (const rule of rules) { + expect(rule.context).toBe('Bank Frick'); + expect(rule.status).toBe('Active'); + expect(rule.targetFiatId).toBeNull(); + expect(rule.minimal).toBeNull(); + expect(rule.maximal).toBeNull(); + expect(rule.deficitStartActionId).toBeNull(); + expect(rule.redundancyStartActionId).toBeNull(); + } + expect(rules.map((r: { targetAssetId: number }) => r.targetAssetId).sort()).toEqual( + [frickEurId, frickChfId].sort(), + ); + }); + + it('is idempotent: re-running up() does not error or duplicate assets/rules', async () => { + const migration = new AddBankFrickCustodyAssets(); + await migration.up(queryRunner); + await migration.up(queryRunner); + + const assetCount = ( + await queryRunner.query(`SELECT COUNT(*)::int AS c FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF')`) + )[0].c; + const ruleCount = ( + await queryRunner.query( + `SELECT COUNT(*)::int AS c FROM "liquidity_management_rule" WHERE "context" = 'Bank Frick'`, + ) + )[0].c; + + expect(assetCount).toBe(2); + expect(ruleCount).toBe(2); + }); + + it('throws when an active Frick bank row cannot be linked (fail-loud post-condition)', async () => { + // Active Frick USD row: the migration only creates EUR/CHF assets, so the bank UPDATE leaves + // this row with assetId NULL and the post-condition must throw rather than half-wire. + await queryRunner.query(` + INSERT INTO "bank" ("name", "iban", "bic", "currency", "receive", "send", "assetId") + VALUES ('Bank Frick', 'LI0000000FRICKUSD0001', 'BFRILI22', 'USD', true, true, NULL) + `); + + const migration = new AddBankFrickCustodyAssets(); + await expect(migration.up(queryRunner)).rejects.toThrow('Bank Frick custody-asset wiring incomplete'); + }); + + it('down() reverses cleanly (rules deleted, bank unlinked, assets deleted)', async () => { + const migration = new AddBankFrickCustodyAssets(); + await migration.up(queryRunner); + await migration.down(queryRunner); + + const assets = await queryRunner.query( + `SELECT COUNT(*)::int AS c FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF')`, + ); + const rules = await queryRunner.query( + `SELECT COUNT(*)::int AS c FROM "liquidity_management_rule" WHERE "context" = 'Bank Frick'`, + ); + const banks = await queryRunner.query( + `SELECT "assetId" FROM "bank" WHERE "name" = 'Bank Frick' AND "currency" IN ('EUR', 'CHF')`, + ); + + expect(assets[0].c).toBe(0); + expect(rules[0].c).toBe(0); + expect(banks.every((b: { assetId: number | null }) => b.assetId == null)).toBe(true); + }); +}); diff --git a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts index 2030776fe1..8b7f204435 100644 --- a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts +++ b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts @@ -1,5 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { createCustomCountry } from 'src/shared/models/country/__mocks__/country.entity.mock'; import { CountryService } from 'src/shared/models/country/country.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; @@ -163,6 +165,27 @@ describe('Bank Frick country routing', () => { }); }); +describe('BankService blockchainToBankName / isBankMatching (Frick)', () => { + beforeEach(() => { + (BankService as unknown as { ibanCache: Map }).ibanCache.clear(); + }); + + it('maps Blockchain.FRICK to IbanBankName.FRICK', () => { + expect(BankService['blockchainToBankName'](Blockchain.FRICK)).toBe(IbanBankName.FRICK); + }); + + it('matches a Frick custody asset against the cached Frick IBAN', () => { + (BankService as unknown as { ibanCache: Map }).ibanCache.set( + `${IbanBankName.FRICK}-EUR`, + 'LI75088110105923K000E', + ); + const asset = createCustomAsset({ blockchain: Blockchain.FRICK, dexName: 'EUR' }); + + expect(BankService.isBankMatching(asset, 'LI75088110105923K000E')).toBe(true); + expect(BankService.isBankMatching(asset, 'OTHER-IBAN')).toBe(false); + }); +}); + describe('Bank.isReconcilable', () => { it('is false only for a Frick row with send=true and receive=false', () => { expect(Object.assign(new Bank(), { name: IbanBankName.FRICK, send: true, receive: false }).isReconcilable).toBe( diff --git a/src/subdomains/supporting/bank/bank/bank.service.ts b/src/subdomains/supporting/bank/bank/bank.service.ts index 2f56facdd1..a994329a88 100644 --- a/src/subdomains/supporting/bank/bank/bank.service.ts +++ b/src/subdomains/supporting/bank/bank/bank.service.ts @@ -127,6 +127,8 @@ export class BankService implements OnModuleInit { return IbanBankName.OLKY; case Blockchain.YAPEAL: return IbanBankName.YAPEAL; + case Blockchain.FRICK: + return IbanBankName.FRICK; default: return undefined; } diff --git a/src/subdomains/supporting/dashboard/__tests__/dashboard-reconciliation.service.spec.ts b/src/subdomains/supporting/dashboard/__tests__/dashboard-reconciliation.service.spec.ts index df8cda063c..37538f108b 100644 --- a/src/subdomains/supporting/dashboard/__tests__/dashboard-reconciliation.service.spec.ts +++ b/src/subdomains/supporting/dashboard/__tests__/dashboard-reconciliation.service.spec.ts @@ -64,6 +64,12 @@ describe('DashboardReconciliationService', () => { expect(service['categorizeAsset'](asset)).toBe('blockchain'); }); + + it('classifies a Frick-blockchain asset without a bank relation as bank', () => { + const asset = { blockchain: Blockchain.FRICK } as Asset; + + expect(service['categorizeAsset'](asset)).toBe('bank'); + }); }); describe('getOverview', () => { diff --git a/src/subdomains/supporting/dashboard/dashboard-reconciliation.service.ts b/src/subdomains/supporting/dashboard/dashboard-reconciliation.service.ts index e0689adc72..e4cd044918 100644 --- a/src/subdomains/supporting/dashboard/dashboard-reconciliation.service.ts +++ b/src/subdomains/supporting/dashboard/dashboard-reconciliation.service.ts @@ -51,6 +51,7 @@ const BANK_BLOCKCHAINS: Blockchain[] = [ Blockchain.CHECKOUT, Blockchain.SUMIXX, Blockchain.YAPEAL, + Blockchain.FRICK, ]; type AssetCategory = 'blockchain' | 'exchange' | 'bank'; diff --git a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts index 17ff623d18..e1f3a7aee0 100644 --- a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts +++ b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts @@ -1,5 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { createCustomExchangeTx } from 'src/integration/exchange/dto/__mocks__/exchange-tx.entity.mock'; import { ExchangeTxType } from 'src/integration/exchange/entities/exchange-tx.entity'; @@ -29,9 +30,10 @@ import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/service import { BankTxRepeatService } from '../../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxReturnService } from '../../bank-tx/bank-tx-return/bank-tx-return.service'; import { createCustomBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; +import { BankTxIndicator, BankTxType } from '../../bank-tx/bank-tx/entities/bank-tx.entity'; import { Bank } from '../../bank/bank/bank.entity'; import { BankService } from '../../bank/bank/bank.service'; -import { olkyEUR, yapealCHF } from '../../bank/bank/__mocks__/bank.entity.mock'; +import { frickEUR, olkyEUR, yapealCHF } from '../../bank/bank/__mocks__/bank.entity.mock'; import { IbanBankName } from '../../bank/bank/dto/bank.dto'; import { createCustomFiatOutput } from '../../fiat-output/__mocks__/fiat-output.entity.mock'; import { PayInService } from '../../payin/services/payin.service'; @@ -1210,4 +1212,81 @@ describe('LogJobService', () => { expect(errorSpy).not.toHaveBeenCalled(); }); }); + + describe('Frick EUR bank <-> Scrypt reconciliation (eurBankIbans completeness)', () => { + beforeEach(() => { + (BankService as unknown as { ibanCache: Map }).ibanCache.clear(); + (BankService as unknown as { ibanCache: Map }).ibanCache.set( + `${IbanBankName.FRICK}-EUR`, + frickEUR.iban, + ); + }); + + afterEach(() => { + (BankService as unknown as { ibanCache: Map }).ibanCache.clear(); + }); + + it('captures a Frick EUR -> Scrypt bank_tx in the Scrypt/EUR pending aggregate (was silently dropped before eurBankIbans included Frick)', async () => { + // sellable=true keeps both assets active so they are not skipped by the asset-log reduce guard + const frickEurAsset = createCustomAsset({ + id: 7001, + blockchain: Blockchain.FRICK, + dexName: 'EUR', + sellable: true, + }); + const scryptEurAsset = createCustomAsset({ + id: 7002, + blockchain: ExchangeName.SCRYPT as unknown as Blockchain, + dexName: 'EUR', + sellable: true, + }); + const assets = [frickEurAsset, scryptEurAsset]; + + jest.spyOn(settingService, 'getCustomBalanceSettings').mockResolvedValue({ assets: [], addresses: [] }); + jest.spyOn(settingService, 'getObj').mockImplementation(async (_key, defaultValue) => defaultValue as never); + jest.spyOn(paymentBalanceService, 'getPaymentBalances').mockResolvedValue(new Map()); + + const bankFor = (name: IbanBankName, currency: string): Bank => + name === IbanBankName.FRICK && currency === 'EUR' + ? frickEUR + : Object.assign(new Bank(), { name, currency, iban: `IBAN_${name}_${currency}`, bic: 'BICTEST' }); + jest.spyOn(bankService, 'getBankInternal').mockImplementation(async (name, currency) => bankFor(name, currency)); + + jest.spyOn(liquidityManagementPipelineService, 'getPendingTx').mockResolvedValue([]); + jest.spyOn(payInService, 'getPendingPayIns').mockResolvedValue([]); + jest.spyOn(buyFiatService, 'getPendingTransactions').mockResolvedValue([]); + jest.spyOn(buyCryptoService, 'getPendingTransactions').mockResolvedValue([]); + jest.spyOn(payoutService, 'getRecentPayoutSentCorrelationIds').mockResolvedValue(new Set()); + jest.spyOn(bankTxService, 'getPendingTx').mockResolvedValue([]); + jest.spyOn(bankTxRepeatService, 'getPendingTx').mockResolvedValue([]); + jest.spyOn(bankTxReturnService, 'getPendingTx').mockResolvedValue([]); + jest.spyOn(bankTxService, 'getRecentBankToBankTx').mockResolvedValue([]); + + // an unmatched (still-pending) debit from Frick's EUR IBAN into Scrypt + const frickToScryptTx = createCustomBankTx({ + id: 90001, + created: Util.hoursBefore(1), + accountIban: frickEUR.iban, + creditDebitIndicator: BankTxIndicator.DEBIT, + instructedCurrency: 'EUR', + instructedAmount: 10000, + amount: 10000, + remittanceInfo: undefined, + }); + jest + .spyOn(bankTxService, 'getRecentExchangeTx') + .mockImplementation(async (_minId, type) => (type === BankTxType.SCRYPT ? [frickToScryptTx] : [])); + jest.spyOn(exchangeTxService, 'getRecentExchangeTx').mockResolvedValue([]); + + const assetLog = await service['getAssetLog'](assets); + + // the Frick/EUR row itself stays zeroed (by design — aggregated under Scrypt/EUR instead), + // so it has no pending plus-balance at all + expect(assetLog[frickEurAsset.id].plusBalance.pending).toBeUndefined(); + + // the Scrypt/EUR row now captures the Frick-sourced pending amount — before the fix this was 0 + // because eurBankIbans excluded Frick's IBAN, so the tx never entered recentEurBankToScryptTx + expect(assetLog[scryptEurAsset.id].plusBalance.pending.toScrypt).toBe(10000); + }); + }); }); diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 02541d7558..d1414154a7 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -380,9 +380,10 @@ export class LogJobService { const olkyBank = await this.bankService.getBankInternal(IbanBankName.OLKY, 'EUR'); const yapealEurBank = await this.bankService.getBankInternal(IbanBankName.YAPEAL, 'EUR'); const yapealChfBank = await this.bankService.getBankInternal(IbanBankName.YAPEAL, 'CHF'); - const eurBankIbans = [yapealEurBank.iban, olkyBank.iban]; + const frickEurBank = await this.bankService.getBankInternal(IbanBankName.FRICK, 'EUR'); + const eurBankIbans = [yapealEurBank.iban, olkyBank.iban, frickEurBank.iban]; const eurBankAssets = assets.filter( - (a) => [Blockchain.OLKYPAY, Blockchain.YAPEAL].includes(a.blockchain) && a.dexName === 'EUR', + (a) => [Blockchain.OLKYPAY, Blockchain.YAPEAL, Blockchain.FRICK].includes(a.blockchain) && a.dexName === 'EUR', ); // pending balances @@ -607,7 +608,7 @@ export class LogJobService { // EUR Scrypt pending: aggregated under Scrypt/EUR instead of per-bank const isEurBankAsset = - [Blockchain.OLKYPAY, Blockchain.YAPEAL].includes(curr.blockchain) && curr.dexName === 'EUR'; + [Blockchain.OLKYPAY, Blockchain.YAPEAL, Blockchain.FRICK].includes(curr.blockchain) && curr.dexName === 'EUR'; const isScryptEurAsset = (curr.blockchain as string) === ExchangeName.SCRYPT && curr.dexName === 'EUR'; // Olky to Yapeal // From 520ee85332633fa6ccd9753ace6d601df9dc3e0d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:23:11 +0200 Subject: [PATCH 10/19] fix(payin): fail closed on ambiguous non-EVM pay-in sends (#4263) * fix(payin): fail closed on ambiguous non-EVM sends (Monero, Zano, Cardano, Lightning) Ports the designate-before-broadcast boundary from #4249 (EVM) to the non-EVM pay-in send strategies. A failure at or after the broadcast now keeps the pay-in in the non-reselectable SENDING status (escalated to SEND_UNCERTAIN by the cron) instead of re-broadcasting a fresh transfer on the next run, which could pay a customer twice. Adds a shared sendWithBroadcastBoundary helper in SendStrategy used by the bitcoin-based, Monero, Zano, Cardano and Lightning send loops, and makes the Lightning completion lookup fail-safe so a post-broadcast lookup error cannot mask a completed send. Completes the non-EVM part of #4192. * fix(payin): extend the fail-closed boundary to ICP, Solana and Tron sends The ICP, Solana and Tron pay-in send strategies share Cardano's staged PREPARED send loop and carried the same pre-broadcast double-send window; route them through sendWithBroadcastBoundary like the other non-EVM chains. Also sort the PayInRepository import. --- .../__tests__/payin-lightning.service.spec.ts | 41 ++++ .../payin/services/payin-lightning.service.ts | 17 +- .../impl/base/__tests__/send.strategy.spec.ts | 180 ++++++++++++++++++ .../send/impl/base/bitcoin-based.strategy.ts | 5 +- .../send/impl/base/cardano.strategy.ts | 7 +- .../strategies/send/impl/base/icp.strategy.ts | 7 +- .../send/impl/base/send.strategy.ts | 40 ++++ .../send/impl/base/solana.strategy.ts | 7 +- .../send/impl/base/tron.strategy.ts | 7 +- .../send/impl/base/zano.strategy.ts | 7 +- .../send/impl/lightning.strategy.ts | 7 +- .../strategies/send/impl/monero.strategy.ts | 7 +- 12 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 src/subdomains/supporting/payin/services/__tests__/payin-lightning.service.spec.ts create mode 100644 src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/send.strategy.spec.ts diff --git a/src/subdomains/supporting/payin/services/__tests__/payin-lightning.service.spec.ts b/src/subdomains/supporting/payin/services/__tests__/payin-lightning.service.spec.ts new file mode 100644 index 0000000000..33e0d3b7d1 --- /dev/null +++ b/src/subdomains/supporting/payin/services/__tests__/payin-lightning.service.spec.ts @@ -0,0 +1,41 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { LightningService } from 'src/integration/lightning/services/lightning.service'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; +import { PayInLightningService } from '../payin-lightning.service'; + +describe('PayInLightningService', () => { + let service: PayInLightningService; + let lightningService: LightningService; + + beforeEach(async () => { + lightningService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [PayInLightningService, { provide: LightningService, useValue: lightningService }], + }).compile(); + + service = module.get(PayInLightningService); + }); + + it('returns the broadcast result with zero fee when the completion lookup fails', async () => { + const payIn = createCustomCryptoInput({ + id: 1, + destinationAddress: BlockchainAddress.create('ln-destination', Blockchain.LIGHTNING), + }); + const outTxId = 'payment-hash'; + const lookupError = new Error('LND unavailable'); + jest.spyOn(lightningService, 'sendTransfer').mockResolvedValue(outTxId); + jest.spyOn(lightningService, 'getTransferCompletionData').mockRejectedValue(lookupError); + const logSpy = jest.spyOn(service['logger'], 'error').mockImplementation(); + + await expect(service.sendTransfer(payIn)).resolves.toEqual({ outTxId, feeAmount: 0 }); + + expect(logSpy).toHaveBeenCalledWith( + `Lightning completion lookup failed after broadcast (tx ${outTxId}) for pay-in ${payIn.id}:`, + lookupError, + ); + }); +}); diff --git a/src/subdomains/supporting/payin/services/payin-lightning.service.ts b/src/subdomains/supporting/payin/services/payin-lightning.service.ts index 8c548e7137..e80994007f 100644 --- a/src/subdomains/supporting/payin/services/payin-lightning.service.ts +++ b/src/subdomains/supporting/payin/services/payin-lightning.service.ts @@ -20,9 +20,22 @@ export class PayInLightningService { } async sendTransfer(payIn: CryptoInput): Promise<{ outTxId: string; feeAmount: number }> { + // Broadcast first; a genuine send failure throws TxBroadcastError from LightningService. const outTxId = await this.service.sendTransfer(payIn.destinationAddress.address, payIn.sendingAmount); - const [isComplete, feeAmount] = await this.service.getTransferCompletionData(outTxId); - if (!isComplete) this.logger.error(`Lightning transfer for pay-in ${payIn.id} was not complete`); + + // The completion lookup only supplies fee data. A failure here must NOT mask a completed broadcast: + // dropping outTxId would let the cron re-send with a new invoice (LND dedup cannot catch it). + let feeAmount = 0; + try { + const [isComplete, fee] = await this.service.getTransferCompletionData(outTxId); + feeAmount = fee; + if (!isComplete) this.logger.error(`Lightning transfer for pay-in ${payIn.id} was not complete`); + } catch (e) { + this.logger.error( + `Lightning completion lookup failed after broadcast (tx ${outTxId}) for pay-in ${payIn.id}:`, + e, + ); + } return { outTxId, feeAmount }; } diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/send.strategy.spec.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/send.strategy.spec.ts new file mode 100644 index 0000000000..c62db44c50 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/__tests__/send.strategy.spec.ts @@ -0,0 +1,180 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Injectable } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { TestSharedModule } from 'src/shared/utils/test.shared.module'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; +import { + CryptoInput, + PayInConfirmationType, + PayInStatus, +} from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayInRepository } from 'src/subdomains/supporting/payin/repositories/payin.repository'; +import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; +import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; +import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { SendStrategyRegistry } from '../send.strategy-registry'; +import { SendStrategy, SendType } from '../send.strategy'; + +@Injectable() +class TestSendStrategy extends SendStrategy { + protected readonly logger = new DfxLogger(TestSendStrategy); + + constructor(private readonly payInRepo: PayInRepository) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.BITCOIN; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get forwardRequired(): boolean { + return true; + } + + doSend(_payIns: CryptoInput[], _type: SendType): Promise { + return Promise.resolve(); + } + + checkConfirmations(_payIns: CryptoInput[], _direction: PayInConfirmationType): Promise { + return Promise.resolve(); + } + + protected getForwardAddress(): BlockchainAddress { + throw new Error('Method not implemented'); + } + + send(payIn: CryptoInput, broadcast: () => Promise<{ outTxId: string; feeAmount?: number }>): Promise { + return this.sendWithBroadcastBoundary(this.payInRepo, payIn, SendType.FORWARD, broadcast); + } +} + +describe('SendStrategy', () => { + let strategy: TestSendStrategy; + let payInRepo: PayInRepository; + + beforeEach(async () => { + payInRepo = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + TestSendStrategy, + { provide: PayInRepository, useValue: payInRepo }, + { provide: TransactionHelper, useValue: createMock() }, + { provide: PricingService, useValue: createMock() }, + { provide: PayoutService, useValue: createMock() }, + { provide: SendStrategyRegistry, useValue: createMock() }, + { provide: AssetService, useValue: createMock() }, + TestUtil.provideConfig(), + ], + }).compile(); + + strategy = module.get(TestSendStrategy); + }); + + it('persists Sending before calling the broadcast sink', async () => { + const payIn = createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }); + const statusesAtSave: Array = []; + const saveSpy = jest.spyOn(payInRepo, 'save').mockImplementation(async (savedPayIn) => { + statusesAtSave.push(savedPayIn.status); + return savedPayIn as CryptoInput; + }); + const broadcastError = new TxBroadcastError('broadcast failed'); + const broadcast = jest.fn().mockRejectedValue(broadcastError); + + await expect(strategy.send(payIn, broadcast)).rejects.toBe(broadcastError); + + expect(statusesAtSave).toEqual([PayInStatus.SENDING]); + expect(saveSpy.mock.invocationCallOrder[0]).toBeLessThan(broadcast.mock.invocationCallOrder[0]); + }); + + it('keeps Sending and rethrows an ambiguous TxBroadcastError', async () => { + const payIn = createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }); + const broadcastError = new TxBroadcastError('RPC timeout'); + jest.spyOn(payInRepo, 'save').mockImplementation(async (savedPayIn) => savedPayIn as CryptoInput); + + await expect(strategy.send(payIn, () => Promise.reject(broadcastError))).rejects.toBe(broadcastError); + + expect(payIn.status).toBe(PayInStatus.SENDING); + expect(payInRepo.save).toHaveBeenCalledTimes(1); + }); + + it('keeps Sending and rethrows when persistence fails after broadcast', async () => { + const payIn = createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }); + const persistenceError = new Error('query timeout'); + jest.spyOn(payInRepo, 'save').mockImplementation(async (savedPayIn) => savedPayIn as CryptoInput); + jest.spyOn(strategy as any, 'updatePayInWithSendData').mockRejectedValue(persistenceError); + + await expect(strategy.send(payIn, () => Promise.resolve({ outTxId: 'tx-id' }))).rejects.toBe(persistenceError); + + expect(payIn.status).toBe(PayInStatus.SENDING); + expect(payInRepo.save).toHaveBeenCalledTimes(1); + }); + + it('restores the captured status and rethrows a plain pre-broadcast error', async () => { + const payIn = createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }); + const preBroadcastError = new Error('fee lookup failed'); + jest.spyOn(payInRepo, 'save').mockImplementation(async (savedPayIn) => savedPayIn as CryptoInput); + + await expect(strategy.send(payIn, () => Promise.reject(preBroadcastError))).rejects.toBe(preBroadcastError); + + expect(payIn.status).toBe(PayInStatus.PREPARED); + expect(payInRepo.save).toHaveBeenCalledTimes(2); + }); + + it('keeps SENDING and rethrows a TxBroadcastError when broadcast resolves with an empty outTxId', async () => { + const payIn = createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }); + const broadcast = () => Promise.resolve({ outTxId: '' }); + jest.spyOn(payInRepo, 'save').mockImplementation(async (savedPayIn) => savedPayIn as CryptoInput); + + await expect(strategy.send(payIn, broadcast)).rejects.toBeInstanceOf(TxBroadcastError); + + expect(payIn.status).toBe(PayInStatus.SENDING); + expect(payInRepo.save).toHaveBeenCalledTimes(1); + }); + + it('persists the forwarded pay-in on a fully successful broadcast', async () => { + const payIn = createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }); + const broadcast = () => Promise.resolve({ outTxId: '0xsent', feeAmount: 0.01 }); + jest.spyOn(payInRepo, 'save').mockImplementation(async (savedPayIn) => savedPayIn as CryptoInput); + jest + .spyOn(strategy as any, 'updatePayInWithSendData') + .mockImplementation(async (payIn: CryptoInput, _type: unknown, outTxId: string, feeAmount?: number) => + payIn.forward(outTxId, feeAmount), + ); + + await expect(strategy.send(payIn, broadcast)).resolves.toBeUndefined(); + + expect(strategy['updatePayInWithSendData']).toHaveBeenCalledWith(payIn, expect.anything(), '0xsent', 0.01); + expect(payInRepo.save).toHaveBeenCalledTimes(2); + expect(payIn.status).toBe(PayInStatus.FORWARDED); + }); + + it('keeps the DB marker fail-closed when the SECOND save fails after a successful broadcast', async () => { + const payIn = createCustomCryptoInput({ id: 1, status: PayInStatus.PREPARED }); + const broadcast = () => Promise.resolve({ outTxId: '0xsent', feeAmount: 0 }); + jest + .spyOn(strategy as any, 'updatePayInWithSendData') + .mockImplementation(async (payIn: CryptoInput, _type: unknown, outTxId: string, feeAmount?: number) => + payIn.forward(outTxId, feeAmount), + ); + const persistenceError = new Error('connection reset'); + jest.spyOn(payInRepo, 'save').mockResolvedValueOnce(payIn).mockRejectedValueOnce(persistenceError); + + await expect(strategy.send(payIn, broadcast)).rejects.toBe(persistenceError); + + expect(payIn.status).toBe(PayInStatus.FORWARDED); + expect(payInRepo.save).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/bitcoin-based.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/bitcoin-based.strategy.ts index 6a4bc916ae..77f684e841 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/bitcoin-based.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/bitcoin-based.strategy.ts @@ -52,10 +52,7 @@ export abstract class BitcoinBasedStrategy extends SendStrategy { CryptoInput.verifyForwardFee(fee, payIn.maxForwardFee, maxFee, payIn.amount); - const { outTxId, feeAmount } = await this.payInService.sendTransfer(payIn); - await this.updatePayInWithSendData(payIn, type, outTxId, feeAmount); - - await this.payInRepo.save(payIn); + await this.sendWithBroadcastBoundary(this.payInRepo, payIn, type, () => this.payInService.sendTransfer(payIn)); } catch (e) { if (e.message.includes('No maximum fee provided')) continue; diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/cardano.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/cardano.strategy.ts index 3be566ad6d..65e3c4ea02 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/cardano.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/cardano.strategy.ts @@ -47,10 +47,9 @@ export abstract class CardanoStrategy extends SendStrategy { } if (payIn.status === PayInStatus.PREPARED) { - const outTxId = await this.sendTransfer(payIn, type); - await this.updatePayInWithSendData(payIn, type, outTxId, payIn.forwardFeeAmount); - - await this.payInRepo.save(payIn); + await this.sendWithBroadcastBoundary(this.payInRepo, payIn, type, () => + this.sendTransfer(payIn, type).then((outTxId) => ({ outTxId, feeAmount: payIn.forwardFeeAmount })), + ); } } catch (e) { if (e.message.includes('No maximum fee provided')) continue; diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts index 0ddbc01dc9..7292173cd8 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts @@ -74,10 +74,9 @@ export abstract class InternetComputerStrategy extends SendStrategy { } if (payIn.status === PayInStatus.PREPARED) { - const outTxId = await this.sendTransfer(payIn, type); - await this.updatePayInWithSendData(payIn, type, outTxId, payIn.forwardFeeAmount); - - await this.payInRepo.save(payIn); + await this.sendWithBroadcastBoundary(this.payInRepo, payIn, type, () => + this.sendTransfer(payIn, type).then((outTxId) => ({ outTxId, feeAmount: payIn.forwardFeeAmount })), + ); } } catch (e) { if (e.message.includes('No maximum fee provided')) continue; diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/send.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/send.strategy.ts index 1be2e2351a..60d75a4a30 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/send.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/send.strategy.ts @@ -1,6 +1,7 @@ import { Inject, OnModuleDestroy, OnModuleInit, forwardRef } from '@nestjs/common'; import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { WalletAccount } from 'src/integration/blockchain/shared/evm/domain/wallet-account'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; @@ -12,6 +13,7 @@ import { PayInConfirmationType, PayInStatus, } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayInRepository } from 'src/subdomains/supporting/payin/repositories/payin.repository'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { @@ -40,6 +42,44 @@ export enum SendType { export abstract class SendStrategy implements OnModuleInit, OnModuleDestroy { protected abstract readonly logger: DfxLogger; + // Persist the in-flight marker BEFORE broadcasting, then classify failures at the broadcast boundary: + // - a plain error before a tx id was obtained is provably pre-broadcast -> restore the captured status + // so the next cron run retries it; + // - a TxBroadcastError, an empty tx id, or a persistence failure AFTER the broadcast is ambiguous + // (the tx may be in flight) -> fail closed by keeping SENDING; processStrandedSendingPayIns escalates + // it to SEND_UNCERTAIN. Mirrors EvmStrategy.dispatch / EvmTokenStrategy.dispatchViaDelegation. + protected async sendWithBroadcastBoundary( + payInRepo: PayInRepository, + payIn: CryptoInput, + type: SendType, + broadcast: () => Promise<{ outTxId: string; feeAmount?: number }>, + ): Promise { + const previousStatus = payIn.status; + + payIn.designateSending(); + await payInRepo.save(payIn); + + let broadcasted = false; + try { + const { outTxId, feeAmount } = await broadcast(); + if (!outTxId) throw new TxBroadcastError(`${this.blockchain} broadcast returned an empty tx id`); + broadcasted = true; + + await this.updatePayInWithSendData(payIn, type, outTxId, feeAmount); + await payInRepo.save(payIn); + } catch (e) { + if (e instanceof TxBroadcastError || broadcasted) { + if (broadcasted) + this.logger.error(`Failed to persist ${this.blockchain} send for pay-in ${payIn.id} after broadcast:`, e); + throw e; + } + + payIn.status = previousStatus; + await payInRepo.save(payIn); + throw e; + } + } + @Inject() private readonly priceProvider: PricingService; @Inject() private readonly payoutService: PayoutService; @Inject(forwardRef(() => TransactionHelper)) private readonly transactionHelper: TransactionHelper; diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/solana.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/solana.strategy.ts index 5d5e0287b9..290072135c 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/solana.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/solana.strategy.ts @@ -58,10 +58,9 @@ export abstract class SolanaStrategy extends SendStrategy { } if (payIn.status === PayInStatus.PREPARED) { - const outTxId = await this.sendTransfer(payIn, type); - await this.updatePayInWithSendData(payIn, type, outTxId, payIn.forwardFeeAmount); - - await this.payInRepo.save(payIn); + await this.sendWithBroadcastBoundary(this.payInRepo, payIn, type, () => + this.sendTransfer(payIn, type).then((outTxId) => ({ outTxId, feeAmount: payIn.forwardFeeAmount })), + ); } } catch (e) { if (e.message.includes('No maximum fee provided')) continue; diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/tron.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/tron.strategy.ts index 92da27cd62..ee05a2b0da 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/tron.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/tron.strategy.ts @@ -47,10 +47,9 @@ export abstract class TronStrategy extends SendStrategy { } if (payIn.status === PayInStatus.PREPARED) { - const outTxId = await this.sendTransfer(payIn, type); - await this.updatePayInWithSendData(payIn, type, outTxId, payIn.forwardFeeAmount); - - await this.payInRepo.save(payIn); + await this.sendWithBroadcastBoundary(this.payInRepo, payIn, type, () => + this.sendTransfer(payIn, type).then((outTxId) => ({ outTxId, feeAmount: payIn.forwardFeeAmount })), + ); } } catch (e) { if (e.message.includes('No maximum fee provided')) continue; diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/zano.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/zano.strategy.ts index 4be295a469..f21b60f238 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/zano.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/zano.strategy.ts @@ -40,10 +40,9 @@ export abstract class ZanoStrategy extends BitcoinBasedStrategy { CryptoInput.verifyForwardFee(fee, payIn.maxForwardFee, maxFee, payIn.amount); - const { outTxId, feeAmount } = await this.payInZanoService.sendTransfer(payIn); - await this.updatePayInWithSendData(payIn, type, outTxId, feeAmount); - - await this.payInRepo.save(payIn); + await this.sendWithBroadcastBoundary(this.payInRepo, payIn, type, () => + this.payInZanoService.sendTransfer(payIn), + ); } catch (e) { if (e.message.includes('No maximum fee provided')) continue; diff --git a/src/subdomains/supporting/payin/strategies/send/impl/lightning.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/lightning.strategy.ts index 447a67e51c..eb5ca54dca 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/lightning.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/lightning.strategy.ts @@ -53,10 +53,9 @@ export class LightningStrategy extends SendStrategy { CryptoInput.verifyForwardFee(fee, payIn.maxForwardFee, maxFee, payIn.amount); - const { outTxId, feeAmount } = await this.lightningService.sendTransfer(payIn); - await this.updatePayInWithSendData(payIn, type, outTxId, feeAmount); - - await this.payInRepo.save(payIn); + await this.sendWithBroadcastBoundary(this.payInRepo, payIn, type, () => + this.lightningService.sendTransfer(payIn), + ); } catch (e) { if (e.message.includes('No maximum fee provided')) continue; diff --git a/src/subdomains/supporting/payin/strategies/send/impl/monero.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/monero.strategy.ts index 92f98fec1d..f9a0e0c837 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/monero.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/monero.strategy.ts @@ -54,10 +54,9 @@ export class MoneroStrategy extends BitcoinBasedStrategy { CryptoInput.verifyForwardFee(fee, payIn.maxForwardFee, maxFee, payIn.amount); - const { outTxId, feeAmount } = await this.moneroService.sendTransfer(payIn); - await this.updatePayInWithSendData(payIn, type, outTxId, feeAmount); - - await this.payInRepo.save(payIn); + await this.sendWithBroadcastBoundary(this.payInRepo, payIn, type, () => + this.moneroService.sendTransfer(payIn), + ); } catch (e) { if (e.message.includes('No maximum fee provided')) continue; From 97c2580123dc006f09201905e1c51d8876768f42 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:33:07 +0200 Subject: [PATCH 11/19] fix: stop the post-cutover prod ERROR-log spam (ledger scan + sign-up race) (#4262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(accounting): log the content-change scan gate-block at verbose, not error The §4.7 G-a gate-block is a DESIGNED self-healing retry signal (a late-settling row's opening is not yet booked), but runContentChangeScan logged every thrown error uniformly at ERROR and re-scanned the same head-of-line row every cron cycle — spamming ~120 ERROR/60min on prod after the cutover. Throw a typed LedgerGateBlockedError for the gate-block and log only that at verbose; every other throw stays a genuine scan error at error (never swallowed). No behavioural change to the retry/cursor semantics. * fix(accounting): stop the crypto_input content-change scan from error-spamming on unbookable rows Two by-design states were logged at ERROR every cron cycle: (1) an asset-less crypto_input (a FAILED pay-in, never settled) hit walletAsset() and threw 'has no asset' on every content-change scan — now skipped (buildSeq0Input returns undefined) when non-settled, while a settled asset-less row still fails loud; (2) a crypto_input with no buyFiat/buyCrypto product and not a payment logged its legitimate seq0 skip at error — now verbose. No functional change to what gets booked. * fix(auth): sign the winner in on a concurrent sign-up address race instead of a 500 A check-then-act race on a brand-new address let two concurrent sign-ups both pass the existence check and both reach createUser → the loser hit SQLSTATE 23505 on user.address UNIQUE, surfaced as a raw 500 and logged raw duplicate-key noise. Consolidate the guard into doSignUp (scoped to the createUser call), keyed on error.code==='23505', so every wallet-signature caller (authenticate, signUp, alby, lnurl) reloads and signs the winner in; any other error still propagates. Removes the now-redundant catch on authenticate. * fix: address PR review — scope sign-up 23505 guard to the address, rename gate-block exception 1) doSignUp: reload by address before signing the winner in, so a 23505 on the address UNIQUE routes to signIn while a 23505 on any OTHER user unique (e.g. ref) rethrows the original error instead of a misleading NotFound. Adds a non-address-23505 rethrow test and asserts the winner receives a real accessToken. 2) Rename LedgerGateBlockedError -> LedgerGateBlockedException to match the repo's *Exception convention (the file was already .exception.ts). --- .../__tests__/crypto-input.consumer.spec.ts | 26 +++++++++++ .../__tests__/ledger-watermark.helper.spec.ts | 39 ++++++++++++++++ .../services/consumers/buy-crypto.consumer.ts | 5 +- .../services/consumers/buy-fiat.consumer.ts | 5 +- .../consumers/crypto-input.consumer.ts | 7 ++- .../ledger-gate-blocked.exception.ts | 9 ++++ .../consumers/ledger-watermark.helper.ts | 15 ++++-- .../auth/__tests__/auth.service.spec.ts | 42 +++++++++++++++++ .../generic/user/models/auth/auth.service.ts | 46 +++++++++++-------- 9 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 src/subdomains/core/accounting/services/consumers/ledger-gate-blocked.exception.ts diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts index 1ed1b08dc1..82d515f334 100644 --- a/src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts +++ b/src/subdomains/core/accounting/services/consumers/__tests__/crypto-input.consumer.spec.ts @@ -2,6 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { TestUtil } from 'src/shared/utils/test.util'; import { Util } from 'src/shared/utils/util'; import { CryptoInput, PayInStatus, PayInType } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; @@ -337,6 +338,8 @@ describe('CryptoInputConsumer', () => { // undefined → no seq0 tx (the watermark still advances; it is a skip, not a failure) it('skips seq0 for a crypto_input with no buyFiat/buyCrypto anchor and not isPayment', async () => { const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const verboseSpy = jest.spyOn(DfxLogger.prototype, 'verbose').mockImplementation(); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); mockBatch([ cryptoInput({ id: 20, amount: 1, asset: { id: BTC_ASSET_ID, uniqueName: 'Bitcoin/BTC' } }), // no anchor ]); @@ -344,6 +347,11 @@ describe('CryptoInputConsumer', () => { expect(booked).toHaveLength(0); // no anchor → no seq0 tx at all expect(JSON.parse(setSpy.mock.calls[0][1]).lastProcessedId).toBe(20); // skip → watermark advances + // the skip is expected/by-design state → logged at verbose, NOT error (no ERROR-dashboard spam every cycle) + expect(verboseSpy).toHaveBeenCalledWith(expect.stringMatching(/has neither buyFiat\/buyCrypto nor isPayment/)); + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringMatching(/has neither buyFiat\/buyCrypto nor isPayment/)); + verboseSpy.mockRestore(); + errorSpy.mockRestore(); }); // §4.4 walletAsset throw: a crypto_input with no asset throws → failure-isolation: watermark NOT advanced @@ -356,6 +364,24 @@ describe('CryptoInputConsumer', () => { expect(setSpy).not.toHaveBeenCalled(); // throw → break before advancing }); + // §4.4 skip-guard: a NON-settled asset-less crypto_input (a FAILED pay-in) surfaced only by the content-change scan + // is skipped (buildSeq0Input → undefined) instead of throwing "has no asset" every cycle → the cursor advances past + // it (no more spam). A SETTLED asset-less row still fails loud (the failure-isolation test above). + it('skips a non-settled asset-less crypto_input in the content-change scan (cursor advances, no throw)', async () => { + const rebookSpy = jest.spyOn(bookingService, 'reverseAndRebookIfChanged').mockResolvedValue(true); + const setSpy = jest.spyOn(settingService, 'set').mockResolvedValue(); + const failed = cryptoInput({ id: 40, amount: 1, asset: null, status: PayInStatus.CREATED }); // not settled + // forward id-scan empty; content-change scan (where.updated) surfaces the asset-less non-settled row + jest + .spyOn(cryptoInputRepo, 'find') + .mockImplementation(({ where }: any) => Promise.resolve(where?.updated != null ? [failed] : [])); + + await consumer.process(); + + expect(rebookSpy).not.toHaveBeenCalled(); // buildSeq0Input returned undefined → no reverse/rebook + expect(setSpy).toHaveBeenCalled(); // cursor advanced past the skipped row (it did NOT throw) + }); + // §4.4 forward-fee idempotency: seq1 already active → bookForwardFee no-ops (only seq0 books) it('does NOT re-book the forward fee (seq1) when it is already active (re-run)', async () => { activeKeys.add('22:1'); // seq1 already booked diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts index a61a086b8b..3d3bb337e4 100644 --- a/src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts +++ b/src/subdomains/core/accounting/services/consumers/__tests__/ledger-watermark.helper.spec.ts @@ -1,7 +1,9 @@ import { createMock } from '@golevelup/ts-jest'; import { ConfigService } from 'src/config/config'; import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { FindOperator, Repository } from 'typeorm'; +import { LedgerGateBlockedException } from '../ledger-gate-blocked.exception'; import { getCutoverBoundary, LedgerWatermark, runContentChangeScan } from '../ledger-watermark.helper'; interface Row { @@ -58,6 +60,7 @@ describe('runContentChangeScan combined (updated, id) cursor', () => { }); afterEach(() => { + jest.restoreAllMocks(); delete process.env.LEDGER_BACKFILL_BATCH_SIZE; new ConfigService(); // restore default batchSize }); @@ -118,6 +121,42 @@ describe('runContentChangeScan combined (updated, id) cursor', () => { expect(written[0].lastReversalScan.getTime()).toBe(t1.getTime()); expect(written[0].lastReversalScanId).toBe(1); }); + + it('logs an expected gate-block (§4.7 G-a) at verbose, not error, and holds the cursor for retry', async () => { + const t = new Date('2026-06-01T00:00:00.000Z'); + const repo = repoOver([{ id: 1, updated: t }], 2); + const wm: LedgerWatermark = { lastProcessedId: 0, lastReversalScan: new Date(0), lastReversalScanId: 0 }; + const verboseSpy = jest.spyOn(DfxLogger.prototype, 'verbose').mockImplementation(); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); + + await runContentChangeScan(settingService, 'test', wm, repo, {}, async () => { + throw new LedgerGateBlockedException('row 1 content-change scan gate-blocked — retry next run (§4.7 G-a)'); + }); + + // expected self-healing gate-block → verbose, never error; cursor held so the row is re-scanned next run + expect(verboseSpy).toHaveBeenCalledWith(expect.stringMatching(/gate-blocked on test 1/), expect.anything()); + expect(errorSpy).not.toHaveBeenCalled(); + expect(written).toHaveLength(0); + }); + + it('logs a genuine scan failure at error (never downgraded or swallowed)', async () => { + const t = new Date('2026-06-01T00:00:00.000Z'); + const repo = repoOver([{ id: 1, updated: t }], 2); + const wm: LedgerWatermark = { lastProcessedId: 0, lastReversalScan: new Date(0), lastReversalScanId: 0 }; + const verboseSpy = jest.spyOn(DfxLogger.prototype, 'verbose').mockImplementation(); + const errorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); + + await runContentChangeScan(settingService, 'test', wm, repo, {}, async () => { + throw new Error('db down'); + }); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringMatching(/Content-change scan failed on test 1/), + expect.anything(), + ); + expect(verboseSpy).not.toHaveBeenCalled(); + expect(written).toHaveLength(0); + }); }); /** diff --git a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts index 63fa35f04f..f94503d917 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts @@ -11,6 +11,7 @@ import { LedgerLeg } from '../../entities/ledger-leg.entity'; import { LedgerTx } from '../../entities/ledger-tx.entity'; import { LedgerAccountService } from '../ledger-account.service'; import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; +import { LedgerGateBlockedException } from './ledger-gate-blocked.exception'; import { getLedgerWatermark, isUnpricedAtCutover, @@ -86,7 +87,9 @@ export class BuyCryptoConsumer { ); return; } - throw new Error(`buy_crypto ${bc.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`); + throw new LedgerGateBlockedException( + `buy_crypto ${bc.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`, + ); } // §6.1 owed-straddling: this consumer booked NOTHING for such a row (the reclassification is anchored in the diff --git a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts index 6e57e738d7..7e54fd8307 100644 --- a/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts @@ -12,6 +12,7 @@ import { LedgerTx } from '../../entities/ledger-tx.entity'; import { LedgerAccountService } from '../ledger-account.service'; import { LedgerBookingService, LedgerLegInput, LedgerTxInput } from '../ledger-booking.service'; import { LedgerMarkCache, LedgerMarkService } from '../ledger-mark.service'; +import { LedgerGateBlockedException } from './ledger-gate-blocked.exception'; import { resolveLegsOrDefer } from './ledger-mark-bridge.helper'; import { getLedgerWatermark, @@ -91,7 +92,9 @@ export class BuyFiatConsumer { ); return; } - throw new Error(`buy_fiat ${bf.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`); + throw new LedgerGateBlockedException( + `buy_fiat ${bf.id} content-change scan gate-blocked — retry next run (§4.7 G-a)`, + ); } // then §4.12 + M3 + M4: on a content change, reverse+rebook the value-coupled regular-sell chain (reclassification diff --git a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts index ebbec01668..82670c343c 100644 --- a/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/crypto-input.consumer.ts @@ -138,6 +138,11 @@ export class CryptoInputConsumer { bookingDate: Date, marks: LedgerMarkCache, ): Promise { + // §4.4 skip-guard: an asset-less crypto_input is a FAILED pay-in (crypto-input.entity: no asset → status FAILED), + // therefore never settled → permanently unbookable. The content-change scan re-selects it every cycle (no status + // filter, §4.12), so skip it quietly (defer) instead of throwing "has no asset" every scan. A SETTLED asset-less row + // would be a genuine anomaly → fall through to walletAsset() and fail loud. + if (!ci.asset && !ci.isSettled) return undefined; const wallet = await this.walletAsset(ci); const mark = wallet.assetId != null ? marks.getMarkAt(wallet.assetId, bookingDate) : undefined; const assetChf = mark != null ? Util.round(mark * ci.amount, 2) : undefined; @@ -181,7 +186,7 @@ export class CryptoInputConsumer { // buyFiat / buyCrypto-swap: 3-leg, amountInChf-anchored received-Cr leg + fx-revaluation plug (§4.4a) const product = this.productAnchor(ci); if (!product) { - this.logger.error(`crypto_input ${ci.id} has neither buyFiat/buyCrypto nor isPayment — skip seq0`); + this.logger.verbose(`crypto_input ${ci.id} has neither buyFiat/buyCrypto nor isPayment — skip seq0`); return undefined; } diff --git a/src/subdomains/core/accounting/services/consumers/ledger-gate-blocked.exception.ts b/src/subdomains/core/accounting/services/consumers/ledger-gate-blocked.exception.ts new file mode 100644 index 0000000000..37bed80b8e --- /dev/null +++ b/src/subdomains/core/accounting/services/consumers/ledger-gate-blocked.exception.ts @@ -0,0 +1,9 @@ +// Thrown by a content-change scan callback (§4.7 G-a) when a late-settling row's opening (received/paymentLink) is not +// yet booked — a DESIGNED self-healing retry signal (leave the cursor, re-scan next run), NOT a failure. +// runContentChangeScan catches it and logs at verbose instead of error so an expected gate-block does not spam ERROR +// every cron cycle; every OTHER throw stays a genuine scan error at error level. +export class LedgerGateBlockedException extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts index 31534a5cbf..cf6e9b86de 100644 --- a/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts +++ b/src/subdomains/core/accounting/services/consumers/ledger-watermark.helper.ts @@ -1,7 +1,8 @@ import { Config } from 'src/config/config'; import { SettingService } from 'src/shared/models/setting/setting.service'; -import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { DfxLogger, LogLevel } from 'src/shared/services/dfx-logger'; import { FindOptionsOrder, FindOptionsRelations, FindOptionsWhere, Raw, Repository } from 'typeorm'; +import { LedgerGateBlockedException } from './ledger-gate-blocked.exception'; // per-source checkpoint (§11.3): id-watermark + content-change scan cursor. // The content-change cursor is the COMBINED (updated, id) pair (§4.12): `lastReversalScan` alone cannot paginate @@ -243,8 +244,16 @@ export async function runContentChangeScan { expect(userDataServiceMock.addServiceProvider).not.toHaveBeenCalled(); }); }); + + describe('concurrent sign-up race (user.address UNIQUE 23505)', () => { + // two tabs sign up the same brand-new address at once: the loser's createUser hits SQLSTATE 23505 on + // user.address. doSignUp signs the winner's (now-committed) account in instead of surfacing a raw 500. + it('signs the winner in when createUser hits a 23505', async () => { + const winner = createCustomUser({ id: 21, userData: account(), custodyProvider }); + custodyProviderServiceMock.getWithMasterKey.mockResolvedValue(custodyProvider); + walletServiceMock.getByIdOrName.mockResolvedValue(createCustomWallet({ name: 'DFX Wallet' })); + userServiceMock.getUserByAddress.mockResolvedValueOnce(null); // pre-create check: address still free + userServiceMock.getUserByAddress.mockResolvedValue(winner); // signIn reload: the winner committed + userServiceMock.createUser.mockRejectedValue( + Object.assign(new Error('duplicate key value'), { code: '23505' }), + ); + + const result = await service.signUp({ address: 'ADDR_RACE', signature: 'SIG' } as any, ip); + + expect(result.accessToken).toBeDefined(); // winner signed in — a real token, not a 500 + expect(userServiceMock.createUser).toHaveBeenCalledTimes(1); + }); + + it('rethrows a non-23505 createUser error (never swallowed)', async () => { + custodyProviderServiceMock.getWithMasterKey.mockResolvedValue(custodyProvider); + walletServiceMock.getByIdOrName.mockResolvedValue(createCustomWallet({ name: 'DFX Wallet' })); + userServiceMock.getUserByAddress.mockResolvedValue(null); + userServiceMock.createUser.mockRejectedValue(new Error('db exploded')); + + await expect(service.signUp({ address: 'ADDR_X', signature: 'SIG' } as any, ip)).rejects.toThrow('db exploded'); + }); + + it('rethrows a 23505 on a non-address constraint (no winner at the address)', async () => { + custodyProviderServiceMock.getWithMasterKey.mockResolvedValue(custodyProvider); + walletServiceMock.getByIdOrName.mockResolvedValue(createCustomWallet({ name: 'DFX Wallet' })); + userServiceMock.getUserByAddress.mockResolvedValue(null); // address NOT taken → not the address race + userServiceMock.createUser.mockRejectedValue( + Object.assign(new Error('duplicate key value'), { code: '23505' }), + ); + + await expect(service.signUp({ address: 'ADDR_Y', signature: 'SIG' } as any, ip)).rejects.toThrow( + 'duplicate key value', + ); + }); + }); }); }); diff --git a/src/subdomains/generic/user/models/auth/auth.service.ts b/src/subdomains/generic/user/models/auth/auth.service.ts index 5eca7e21e4..24cb2d4907 100644 --- a/src/subdomains/generic/user/models/auth/auth.service.ts +++ b/src/subdomains/generic/user/models/auth/auth.service.ts @@ -130,10 +130,7 @@ export class AuthService { return existingUser ? this.doSignIn(existingUser, dto, userIp, false) - : this.doSignUp(dto, userIp, false, userDataId, userId).catch((e) => { - if (e.message?.includes('duplicate key')) return this.signIn(dto, userIp, false); - throw e; - }); + : this.doSignUp(dto, userIp, false, userDataId, userId); } async signUp(dto: SignUpDto, userIp: string, isCustodial = false): Promise { @@ -169,20 +166,33 @@ export class AuthService { if (dto.key) dto.signature = [dto.signature, dto.key].join(';'); const wallet = await this.walletService.getByIdOrName(dto.walletId, dto.wallet); - const user = await this.userService.createUser( - { - ...dto, - ip: userIp, - origin: ref?.origin, - wallet: wallet ?? userData?.wallet, - custodyProvider, - userData, - primaryUser, - }, - dto.specialCode ?? dto.discountCode, - dto.moderator, - dto.language, - ); + let user: User; + try { + user = await this.userService.createUser( + { + ...dto, + ip: userIp, + origin: ref?.origin, + wallet: wallet ?? userData?.wallet, + custodyProvider, + userData, + primaryUser, + }, + dto.specialCode ?? dto.discountCode, + dto.moderator, + dto.language, + ); + } catch (e) { + // A concurrent sign-up of the same address committed first (SQLSTATE 23505 on the user.address UNIQUE): reload and + // sign THAT (now-committed) account in instead of surfacing the raw duplicate-key error as a 500. Reloading by + // address also scopes the guard to the real address race — a 23505 on any OTHER user unique (e.g. ref) finds no + // winner and rethrows the original error. Mirrors the ledger findOrCreate reload-or-rethrow, and makes every + // wallet-signature caller (authenticate, signUp, alby, lnurl) idempotent under the race. + if ((e as { code?: string }).code === '23505' && (await this.userService.getUserByAddress(dto.address))) { + return this.signIn(dto, userIp, isCustodial); + } + throw e; + } // service-provider marker (e.g. RealUnit) keyed on the login wallet: must be set at first server // contact so tenant dashboards see the account before any registration step, and set on the account From ff28947c6349843e6db89a489a1678b2187acea2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:24:23 +0200 Subject: [PATCH 12/19] feat(payin): add verification-gated admin recovery for SendUncertain sends (#4265) --- .../payin/controllers/payin.controller.ts | 11 ++ .../payin/dto/retry-payin-send.dto.ts | 21 +++ .../__tests__/crypto-input.entity.spec.ts | 38 ++++- .../payin/entities/crypto-input.entity.ts | 16 ++ .../services/__tests__/payin.service.spec.ts | 137 +++++++++++++++++- .../payin/services/payin.service.ts | 33 ++++- 6 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 src/subdomains/supporting/payin/dto/retry-payin-send.dto.ts diff --git a/src/subdomains/supporting/payin/controllers/payin.controller.ts b/src/subdomains/supporting/payin/controllers/payin.controller.ts index 7340ab0536..de57daf615 100644 --- a/src/subdomains/supporting/payin/controllers/payin.controller.ts +++ b/src/subdomains/supporting/payin/controllers/payin.controller.ts @@ -1,10 +1,13 @@ import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { RetryPayInSendDto } from '../dto/retry-payin-send.dto'; import { CryptoInput } from '../entities/crypto-input.entity'; import { PayInEntry, PollAddressDto } from '../interfaces'; import { PayInService } from '../services/payin.service'; @@ -33,4 +36,12 @@ export class PayInController { dto.toBlock, ); } + + @Post('retry') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async retryUncertainSend(@GetJwt() jwt: JwtPayload, @Body() dto: RetryPayInSendDto): Promise { + return this.payInService.retryUncertainSend(jwt.account, dto); + } } diff --git a/src/subdomains/supporting/payin/dto/retry-payin-send.dto.ts b/src/subdomains/supporting/payin/dto/retry-payin-send.dto.ts new file mode 100644 index 0000000000..be94f07902 --- /dev/null +++ b/src/subdomains/supporting/payin/dto/retry-payin-send.dto.ts @@ -0,0 +1,21 @@ +import { Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class RetryPayInSendDto { + @IsNotEmpty() + @IsInt() + @Type(() => Number) + id: number; + + // Explicit operator confirmation that on-chain absence was verified — the endpoint must + // refuse anything but literal true. + @IsNotEmpty() + @IsBoolean() + noBroadcastVerified: boolean; + + // What was checked (explorer links, ticket, tooling reference) — becomes part of the audit log. + @IsNotEmpty() + @IsString() + @MaxLength(1024) + verificationReference: string; +} diff --git a/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts b/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts index ca6724c5c4..5e1fafe577 100644 --- a/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts +++ b/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts @@ -1,5 +1,5 @@ import { createCustomCryptoInput } from '../__mocks__/crypto-input.entity.mock'; -import { PayInStatus } from '../crypto-input.entity'; +import { PayInAction, PayInStatus } from '../crypto-input.entity'; describe('CryptoInput', () => { describe('#designateSending(...)', () => { @@ -24,4 +24,40 @@ describe('CryptoInput', () => { expect(entity.status).toBe(PayInStatus.FAILED); }); }); + + describe('#resetSend(...)', () => { + it('returns a FORWARD SendUncertain pay-in to Acknowledged', () => { + const entity = createCustomCryptoInput({ + id: 1, + status: PayInStatus.SEND_UNCERTAIN, + action: PayInAction.FORWARD, + }); + + const [id, update] = entity.resetSend(); + + expect(id).toBe(1); + expect(update).toEqual({ status: PayInStatus.ACKNOWLEDGED }); + expect(entity.status).toBe(PayInStatus.ACKNOWLEDGED); + }); + + it('returns a RETURN SendUncertain pay-in to ToReturn', () => { + const entity = createCustomCryptoInput({ id: 2, status: PayInStatus.SEND_UNCERTAIN, action: PayInAction.RETURN }); + + const [id, update] = entity.resetSend(); + + expect(id).toBe(2); + expect(update).toEqual({ status: PayInStatus.TO_RETURN }); + expect(entity.status).toBe(PayInStatus.TO_RETURN); + }); + + it('throws when the action is neither Forward nor Return', () => { + const entity = createCustomCryptoInput({ + id: 3, + status: PayInStatus.SEND_UNCERTAIN, + action: PayInAction.WAITING, + }); + + expect(() => entity.resetSend()).toThrow(); + }); + }); }); diff --git a/src/subdomains/supporting/payin/entities/crypto-input.entity.ts b/src/subdomains/supporting/payin/entities/crypto-input.entity.ts index 5bb5d5a067..09ac9fd4d4 100644 --- a/src/subdomains/supporting/payin/entities/crypto-input.entity.ts +++ b/src/subdomains/supporting/payin/entities/crypto-input.entity.ts @@ -274,6 +274,22 @@ export class CryptoInput extends IEntity { return this; } + // Recover an operator-verified not-broadcast SendUncertain pay-in: return it to its direction's + // initial send status so the send cron re-selects it and re-runs the full designate-before-broadcast + // flow. Staged chains rebuild a fresh preparation, overwriting any prior preparation artifacts. + resetSend(): UpdateResult { + if (![PayInAction.FORWARD, PayInAction.RETURN].includes(this.action)) + throw new Error(`Cannot reset send for pay-in ${this.id} with action ${this.action}`); + + const update: Partial = { + status: this.action === PayInAction.RETURN ? PayInStatus.TO_RETURN : PayInStatus.ACKNOWLEDGED, + }; + + Object.assign(this, update); + + return [this.id, update]; + } + confirm(direction: PayInConfirmationType, forwardRequired: boolean): UpdateResult { let update: Partial = {}; diff --git a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts index eea0e1d954..477368b416 100644 --- a/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts +++ b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { mock } from 'jest-mock-extended'; import { ConfigService } from 'src/config/config'; import { Util } from 'src/shared/utils/util'; @@ -6,6 +6,7 @@ import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/serv import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { In, IsNull, LessThan, Not } from 'typeorm'; +import { RetryPayInSendDto } from '../../dto/retry-payin-send.dto'; import { createCustomCryptoInput } from '../../entities/__mocks__/crypto-input.entity.mock'; import { PayInAction, PayInStatus, PayInType } from '../../entities/crypto-input.entity'; import { PayInRepository } from '../../repositories/payin.repository'; @@ -177,4 +178,138 @@ describe('PayInService designate-before-broadcast safeguards', () => { relations: { buyCrypto: true, buyFiat: true }, }); }); + + describe('#retryUncertainSend(...)', () => { + const accountId = 42; + const baseDto: RetryPayInSendDto = { + id: 1, + noBroadcastVerified: true, + verificationReference: 'explorer: no tx; ticket SUP-123', + }; + + it('throws NotFoundException when the pay-in does not exist', async () => { + jest.spyOn(payInRepository, 'findOneBy').mockResolvedValue(null); + const updateSpy = jest.spyOn(payInRepository, 'update'); + + await expect(service.retryUncertainSend(accountId, baseDto)).rejects.toThrow(NotFoundException); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when the status is not SendUncertain', async () => { + const payIn = createCustomCryptoInput({ + id: 1, + status: PayInStatus.SENDING, + action: PayInAction.FORWARD, + outTxId: null, + returnTxId: null, + }); + jest.spyOn(payInRepository, 'findOneBy').mockResolvedValue(payIn); + const updateSpy = jest.spyOn(payInRepository, 'update'); + + await expect(service.retryUncertainSend(accountId, baseDto)).rejects.toThrow(BadRequestException); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when outTxId is set (must be reconciled, not retried)', async () => { + const payIn = createCustomCryptoInput({ + id: 1, + status: PayInStatus.SEND_UNCERTAIN, + action: PayInAction.FORWARD, + outTxId: 'OUT_TX_ALREADY_SET', + returnTxId: null, + }); + jest.spyOn(payInRepository, 'findOneBy').mockResolvedValue(payIn); + const updateSpy = jest.spyOn(payInRepository, 'update'); + + await expect(service.retryUncertainSend(accountId, baseDto)).rejects.toThrow(BadRequestException); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when returnTxId is set (must be reconciled, not retried)', async () => { + const payIn = createCustomCryptoInput({ + id: 1, + status: PayInStatus.SEND_UNCERTAIN, + action: PayInAction.RETURN, + outTxId: null, + returnTxId: 'RETURN_TX_ALREADY_SET', + }); + jest.spyOn(payInRepository, 'findOneBy').mockResolvedValue(payIn); + const updateSpy = jest.spyOn(payInRepository, 'update'); + + await expect(service.retryUncertainSend(accountId, baseDto)).rejects.toThrow(BadRequestException); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when noBroadcastVerified is not true and never updates', async () => { + const payIn = createCustomCryptoInput({ + id: 1, + status: PayInStatus.SEND_UNCERTAIN, + action: PayInAction.FORWARD, + outTxId: null, + returnTxId: null, + }); + jest.spyOn(payInRepository, 'findOneBy').mockResolvedValue(payIn); + const updateSpy = jest.spyOn(payInRepository, 'update'); + + await expect(service.retryUncertainSend(accountId, { ...baseDto, noBroadcastVerified: false })).rejects.toThrow( + BadRequestException, + ); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('resets a FORWARD pay-in to Acknowledged with a conditional update on SendUncertain', async () => { + const payIn = createCustomCryptoInput({ + id: 1, + status: PayInStatus.SEND_UNCERTAIN, + action: PayInAction.FORWARD, + outTxId: null, + returnTxId: null, + }); + jest.spyOn(payInRepository, 'findOneBy').mockResolvedValue(payIn); + const updateSpy = jest.spyOn(payInRepository, 'update').mockResolvedValue({ affected: 1 } as any); + const infoSpy = jest.spyOn(service['logger'], 'info').mockImplementation(); + + await service.retryUncertainSend(accountId, baseDto); + + expect(updateSpy).toHaveBeenCalledWith( + { id: payIn.id, status: PayInStatus.SEND_UNCERTAIN }, + { status: PayInStatus.ACKNOWLEDGED }, + ); + expect(infoSpy).toHaveBeenCalled(); + }); + + it('resets a RETURN pay-in to ToReturn with a conditional update on SendUncertain', async () => { + const payIn = createCustomCryptoInput({ + id: 1, + status: PayInStatus.SEND_UNCERTAIN, + action: PayInAction.RETURN, + outTxId: null, + returnTxId: null, + }); + jest.spyOn(payInRepository, 'findOneBy').mockResolvedValue(payIn); + const updateSpy = jest.spyOn(payInRepository, 'update').mockResolvedValue({ affected: 1 } as any); + jest.spyOn(service['logger'], 'info').mockImplementation(); + + await service.retryUncertainSend(accountId, baseDto); + + expect(updateSpy).toHaveBeenCalledWith( + { id: payIn.id, status: PayInStatus.SEND_UNCERTAIN }, + { status: PayInStatus.TO_RETURN }, + ); + }); + + it('throws ConflictException when the conditional update affects no rows (concurrent state change)', async () => { + const payIn = createCustomCryptoInput({ + id: 1, + status: PayInStatus.SEND_UNCERTAIN, + action: PayInAction.FORWARD, + outTxId: null, + returnTxId: null, + }); + jest.spyOn(payInRepository, 'findOneBy').mockResolvedValue(payIn); + jest.spyOn(payInRepository, 'update').mockResolvedValue({ affected: 0 } as any); + + await expect(service.retryUncertainSend(accountId, baseDto)).rejects.toThrow(ConflictException); + }); + }); }); diff --git a/src/subdomains/supporting/payin/services/payin.service.ts b/src/subdomains/supporting/payin/services/payin.service.ts index 89b2befd06..e84c461058 100644 --- a/src/subdomains/supporting/payin/services/payin.service.ts +++ b/src/subdomains/supporting/payin/services/payin.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; @@ -19,6 +19,7 @@ import { In, IsNull, LessThan, MoreThan, Not } from 'typeorm'; import { DepositRoute } from '../../address-pool/route/deposit-route.entity'; import { TransactionSourceType, TransactionTypeInternal } from '../../payment/entities/transaction.entity'; import { TransactionService } from '../../payment/services/transaction.service'; +import { RetryPayInSendDto } from '../dto/retry-payin-send.dto'; import { CryptoInput, CryptoInputInFlightSendStatus, @@ -255,6 +256,36 @@ export class PayInService { await this.payInRepository.save(_payIn); } + async retryUncertainSend(accountId: number, dto: RetryPayInSendDto): Promise { + const payIn = await this.payInRepository.findOneBy({ id: dto.id }); + if (!payIn) throw new NotFoundException('CryptoInput not found'); + + if (payIn.status !== PayInStatus.SEND_UNCERTAIN) + throw new BadRequestException( + `CryptoInput ${dto.id} cannot be retried in status ${payIn.status}, expected ${PayInStatus.SEND_UNCERTAIN}`, + ); + + // A pay-in that already carries an out/return tx id did broadcast — reconcile against the chain, never re-send. + if (payIn.outTxId || payIn.returnTxId) + throw new BadRequestException( + `CryptoInput ${dto.id} has ${ + payIn.outTxId ? `outTxId ${payIn.outTxId}` : `returnTxId ${payIn.returnTxId}` + } and must be reconciled, not retried`, + ); + + if (dto.noBroadcastVerified !== true) + throw new BadRequestException('On-chain absence must be verified and confirmed (noBroadcastVerified)'); + + // Atomic conditional transition — a concurrent state change (e.g. late confirmation) must not be resurrected. + const [, update] = payIn.resetSend(); + const result = await this.payInRepository.update({ id: payIn.id, status: PayInStatus.SEND_UNCERTAIN }, update); + if (!result.affected) throw new ConflictException(`CryptoInput ${dto.id} changed state concurrently, not retried`); + + this.logger.info( + `Manual pay-in send retry authorized for input ${dto.id} by account ${accountId}: status ${PayInStatus.SEND_UNCERTAIN} -> ${update.status}, reference: ${dto.verificationReference}`, + ); + } + // --- JOBS --- // @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) From 3f235b33d6bbca072ab1e43632651ffb1d6cf9a0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:05:20 +0200 Subject: [PATCH 13/19] fix(bank): attribute Bank Frick CHF bank-to-Scrypt transfers in the reconciliation (#4257) * fix(bank): attribute Bank Frick CHF bank-to-Scrypt transfers in the reconciliation Symmetric to the EUR fix: the CHF side of the log-job Scrypt reconciliation hardcoded the Yapeal CHF IBAN, so a Bank Frick CHF bank_tx into or out of Scrypt was dropped from the pending computation (Bank Frick CHF is a newly-active fallback account). Widen the two CHF Scrypt bank_tx filters to include the Bank Frick CHF IBAN, so a Frick CHF bank-to-Scrypt transfer attributes to the Frick/CHF asset and a Frick CHF credit can settle a previously-unmatched pending withdrawal. Yapeal CHF stays bit-identical when there is no Frick activity, and each bank_tx attributes to exactly one asset (per-row IBAN match), so there is no double-count. The Scrypt-withdrawal attribution target is deliberately left hardcoded to the Yapeal CHF IBAN: an exchange-tx carries no field identifying the destination bank, so making that target per-asset would tautologically match every CHF bank asset and double-count the same withdrawal. Leaving it keeps the total counted exactly once. * docs(log): clarify the CHF Scrypt attribution comment --- .../log/__tests__/log-job.service.spec.ts | 113 +++++++++++++++++- .../supporting/log/log-job.service.ts | 11 +- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts index e1f3a7aee0..31d92de51c 100644 --- a/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts +++ b/src/subdomains/supporting/log/__tests__/log-job.service.spec.ts @@ -33,7 +33,7 @@ import { createCustomBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.enti import { BankTxIndicator, BankTxType } from '../../bank-tx/bank-tx/entities/bank-tx.entity'; import { Bank } from '../../bank/bank/bank.entity'; import { BankService } from '../../bank/bank/bank.service'; -import { frickEUR, olkyEUR, yapealCHF } from '../../bank/bank/__mocks__/bank.entity.mock'; +import { frickCHF, frickEUR, olkyEUR, yapealCHF } from '../../bank/bank/__mocks__/bank.entity.mock'; import { IbanBankName } from '../../bank/bank/dto/bank.dto'; import { createCustomFiatOutput } from '../../fiat-output/__mocks__/fiat-output.entity.mock'; import { PayInService } from '../../payin/services/payin.service'; @@ -1289,4 +1289,115 @@ describe('LogJobService', () => { expect(assetLog[scryptEurAsset.id].plusBalance.pending.toScrypt).toBe(10000); }); }); + + describe('Frick CHF bank <-> Scrypt reconciliation (chfBankIbans completeness)', () => { + beforeEach(() => { + (BankService as unknown as { ibanCache: Map }).ibanCache.clear(); + (BankService as unknown as { ibanCache: Map }).ibanCache.set( + `${IbanBankName.YAPEAL}-CHF`, + yapealCHF.iban, + ); + (BankService as unknown as { ibanCache: Map }).ibanCache.set( + `${IbanBankName.FRICK}-CHF`, + frickCHF.iban, + ); + }); + + afterEach(() => { + (BankService as unknown as { ibanCache: Map }).ibanCache.clear(); + }); + + function setupChfScryptAssetLog(scryptBankTx: ReturnType[]) { + jest.spyOn(settingService, 'getCustomBalanceSettings').mockResolvedValue({ assets: [], addresses: [] }); + jest.spyOn(settingService, 'getObj').mockImplementation(async (_key, defaultValue) => defaultValue as never); + jest.spyOn(paymentBalanceService, 'getPaymentBalances').mockResolvedValue(new Map()); + + const bankFor = (name: IbanBankName, currency: string): Bank => { + if (name === IbanBankName.YAPEAL && currency === 'CHF') return yapealCHF; + if (name === IbanBankName.FRICK && currency === 'CHF') return frickCHF; + return Object.assign(new Bank(), { name, currency, iban: `IBAN_${name}_${currency}`, bic: 'BICTEST' }); + }; + jest.spyOn(bankService, 'getBankInternal').mockImplementation(async (name, currency) => bankFor(name, currency)); + + jest.spyOn(liquidityManagementPipelineService, 'getPendingTx').mockResolvedValue([]); + jest.spyOn(payInService, 'getPendingPayIns').mockResolvedValue([]); + jest.spyOn(buyFiatService, 'getPendingTransactions').mockResolvedValue([]); + jest.spyOn(buyCryptoService, 'getPendingTransactions').mockResolvedValue([]); + jest.spyOn(payoutService, 'getRecentPayoutSentCorrelationIds').mockResolvedValue(new Set()); + jest.spyOn(bankTxService, 'getPendingTx').mockResolvedValue([]); + jest.spyOn(bankTxRepeatService, 'getPendingTx').mockResolvedValue([]); + jest.spyOn(bankTxReturnService, 'getPendingTx').mockResolvedValue([]); + jest.spyOn(bankTxService, 'getRecentBankToBankTx').mockResolvedValue([]); + jest + .spyOn(bankTxService, 'getRecentExchangeTx') + .mockImplementation(async (_minId, type) => (type === BankTxType.SCRYPT ? scryptBankTx : [])); + jest.spyOn(exchangeTxService, 'getRecentExchangeTx').mockResolvedValue([]); + } + + // sellable=true keeps assets active so they are not skipped by the asset-log reduce guard + const yapealChfCustodyAsset = (): Asset => + createCustomAsset({ + id: 8001, + blockchain: Blockchain.YAPEAL, + dexName: 'CHF', + sellable: true, + }); + const frickChfCustodyAsset = (): Asset => + createCustomAsset({ + id: 8002, + blockchain: Blockchain.FRICK, + dexName: 'CHF', + sellable: true, + }); + + it('keeps Yapeal/CHF Bank->Scrypt pending bit-identical when there is no Frick CHF activity', async () => { + const yapealAsset = yapealChfCustodyAsset(); + const frickAsset = frickChfCustodyAsset(); + + // unmatched debit from Yapeal CHF only — same rows as before chfBankIbans generalization + const yapealToScryptTx = createCustomBankTx({ + id: 91001, + created: Util.hoursBefore(1), + accountIban: yapealCHF.iban, + creditDebitIndicator: BankTxIndicator.DEBIT, + instructedCurrency: 'CHF', + instructedAmount: 5000, + amount: 5000, + remittanceInfo: undefined, + }); + setupChfScryptAssetLog([yapealToScryptTx]); + + const assetLog = await service['getAssetLog']([yapealAsset, frickAsset]); + + // Yapeal/CHF still owns the pending Bank->Scrypt amount (accountIban fallback matches Yapeal only) + expect(assetLog[yapealAsset.id].plusBalance.pending.toScrypt).toBe(5000); + // Frick/CHF has no matching BankTx — must stay at zero / undefined pending + expect(assetLog[frickAsset.id].plusBalance.pending?.toScrypt ?? 0).toBe(0); + }); + + it('attributes Frick CHF -> Scrypt bank_tx to Frick/CHF and not to Yapeal/CHF (no double-count)', async () => { + const yapealAsset = yapealChfCustodyAsset(); + const frickAsset = frickChfCustodyAsset(); + + // unmatched debit from Frick's CHF IBAN into Scrypt + const frickToScryptTx = createCustomBankTx({ + id: 91002, + created: Util.hoursBefore(1), + accountIban: frickCHF.iban, + creditDebitIndicator: BankTxIndicator.DEBIT, + instructedCurrency: 'CHF', + instructedAmount: 7500, + amount: 7500, + remittanceInfo: undefined, + }); + setupChfScryptAssetLog([frickToScryptTx]); + + const assetLog = await service['getAssetLog']([yapealAsset, frickAsset]); + + // Frick/CHF custody asset receives the pending Bank->Scrypt amount via accountIban fallback + expect(assetLog[frickAsset.id].plusBalance.pending.toScrypt).toBe(7500); + // Yapeal/CHF must not pick up the Frick debit (no double-count; baseline stays empty) + expect(assetLog[yapealAsset.id].plusBalance.pending?.toScrypt ?? 0).toBe(0); + }); + }); }); diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index d1414154a7..4a2f95c7eb 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -380,8 +380,10 @@ export class LogJobService { const olkyBank = await this.bankService.getBankInternal(IbanBankName.OLKY, 'EUR'); const yapealEurBank = await this.bankService.getBankInternal(IbanBankName.YAPEAL, 'EUR'); const yapealChfBank = await this.bankService.getBankInternal(IbanBankName.YAPEAL, 'CHF'); + const frickChfBank = await this.bankService.getBankInternal(IbanBankName.FRICK, 'CHF'); const frickEurBank = await this.bankService.getBankInternal(IbanBankName.FRICK, 'EUR'); const eurBankIbans = [yapealEurBank.iban, olkyBank.iban, frickEurBank.iban]; + const chfBankIbans = [yapealChfBank.iban, frickChfBank.iban]; const eurBankAssets = assets.filter( (a) => [Blockchain.OLKYPAY, Blockchain.YAPEAL, Blockchain.FRICK].includes(a.blockchain) && a.dexName === 'EUR', ); @@ -500,9 +502,9 @@ export class LogJobService { k.address === yapealEurBank.bic.padEnd(11, 'XXX'), ); - // CHF: Yapeal -> Scrypt + // CHF: Bank (Yapeal/Frick) -> Scrypt const chfSenderScryptBankTx = recentScryptBankTx.filter( - (b) => b.accountIban === yapealChfBank.iban && b.creditDebitIndicator === BankTxIndicator.DEBIT, + (b) => chfBankIbans.includes(b.accountIban) && b.creditDebitIndicator === BankTxIndicator.DEBIT, ); const chfReceiverScryptExchangeTx = recentScryptExchangeTx.filter( (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'CHF', @@ -538,12 +540,13 @@ export class LogJobService { (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'EUR', ); - // CHF: Scrypt -> Yapeal + // CHF: Scrypt -> Bank (Yapeal/Frick) — receiver list is matching-only; pending attribution stays Yapeal-targeted + // (ExchangeTx has no bank destination field, so a per-currency target would double-count across CHF bank assets) const chfSenderScryptExchangeTx = recentScryptExchangeTx.filter( (k) => k.type === ExchangeTxType.WITHDRAWAL && k.status !== 'failed' && k.currency === 'CHF', ); const chfReceiverScryptBankTx = recentScryptBankTx.filter( - (b) => b.accountIban === yapealChfBank.iban && b.creditDebitIndicator === BankTxIndicator.CREDIT, + (b) => chfBankIbans.includes(b.accountIban) && b.creditDebitIndicator === BankTxIndicator.CREDIT, ); // EUR: Scrypt -> Bank From 2bd7a5e89ae1d3aa74d7e9b313966936c6b4fd89 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:06:02 +0200 Subject: [PATCH 14/19] fix(fee): resolve the sell-side payout bank for fee scoping instead of hardcoding Yapeal (#4254) BuyFiat.refreshFee() hardcoded the sell-side charged fee's bankOut to IbanBankName.YAPEAL, so bank-scoped Fee rows for Olkypay or Bank Frick could never match on a sell even when those banks execute the payout. Extract the payout-bank selection out of getPayoutAccount() into a reusable, side-effect-free FiatOutputService.selectPayoutBank() (single source of truth; getPayoutAccount() becomes a thin wrapper with unchanged behaviour) and call it from refreshFee() to resolve bankOut dynamically. Fail-closed: an unresolved payout bank is passed as undefined, which can only under-match a bank-scoped Fee, never apply one scoped to a bank that is not actually paying out. --- .../buy-fiat-preparation.service.spec.ts | 113 +++++++++++++++++- .../services/buy-fiat-preparation.service.ts | 21 +++- .../__tests__/fiat-output-job.service.spec.ts | 8 ++ .../fiat-output/fiat-output-job.service.ts | 56 ++------- .../fiat-output/fiat-output.service.ts | 58 +++++++++ 5 files changed, 205 insertions(+), 51 deletions(-) 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 656bc2334e..fc6ae59cec 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 @@ -3,7 +3,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Config, ConfigService } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { ScorechainScreeningService } from 'src/integration/scorechain/services/scorechain-screening.service'; +import { createCustomCountry } from 'src/shared/models/country/__mocks__/country.entity.mock'; +import { Country } from 'src/shared/models/country/country.entity'; import { CountryService } from 'src/shared/models/country/country.service'; +import { createCustomFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; 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'; @@ -12,13 +15,21 @@ import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outc 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'; +import { createCustomSell } from 'src/subdomains/core/sell-crypto/route/__mocks__/sell.entity.mock'; import { ScorechainDocumentService } from 'src/subdomains/generic/kyc/services/scorechain-document.service'; +import { createCustomBank } from 'src/subdomains/supporting/bank/bank/__mocks__/bank.entity.mock'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; +import { FiatOutputType } from 'src/subdomains/supporting/fiat-output/fiat-output.entity'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; +import { InternalFeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; +import { CryptoPaymentMethod, FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { Price } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { IsNull } from 'typeorm'; +import { IsNull, UpdateResult } from 'typeorm'; import { createCustomBuyFiat } from '../../__mocks__/buy-fiat.entity.mock'; import { BuyFiatRepository } from '../../buy-fiat.repository'; import { BuyFiatNotificationService } from '../buy-fiat-notification.service'; @@ -106,6 +117,106 @@ describe('BuyFiatPreparationService', () => { jest.spyOn(entity, 'amlCheckAndFillUp').mockReturnValue([entity.id, { amlCheck: CheckStatus.PASS }] as any); } + describe('refreshFee', () => { + const fee: InternalFeeDto = { + fees: [], + min: 0, + rate: 0.01, + fixed: 0, + bank: 0, + bankFixed: 0, + bankPercent: 0, + partner: 0, + network: 0, + total: 0.01, + payoutRefBonus: false, + }; + + function arrangeRefreshFee( + currency: string, + iban: string, + bank: Bank | undefined, + ): { entity: ReturnType; country: Country } { + const entity = createCustomBuyFiat({ + id: 1, + outputAsset: createCustomFiat({ name: currency }), + percentFee: 0.01, + sell: createCustomSell({ iban }), + }); + const country = createCustomCountry({ symbol: iban.substring(0, 2) }); + + jest.spyOn(buyFiatRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(buyFiatRepo, 'update').mockResolvedValue(Object.assign(new UpdateResult(), { affected: 1 })); + jest + .spyOn(pricingService, 'getPrice') + .mockResolvedValue(Price.create(entity.cryptoInput.asset.name, currency, 1)); + jest.spyOn(countryService, 'getCountryWithSymbol').mockResolvedValue(country); + jest.spyOn(fiatOutputService, 'selectPayoutBank').mockResolvedValue({ accountIban: bank?.iban, bank }); + jest.spyOn(transactionHelper, 'getTxFeeInfos').mockResolvedValue(fee); + + return { entity, country }; + } + + it.each<[string, string, string, IbanBankName]>([ + ['CHF through Yapeal', 'CHF', 'CH1234567890', IbanBankName.YAPEAL], + ['EUR through Olkypay', 'EUR', 'DE1234567890', IbanBankName.OLKY], + ['Frick-eligible EUR through Bank Frick', 'EUR', 'LI1234567890', IbanBankName.FRICK], + ])('predicts %s and passes the selected bank to fee matching', async (_, currency, iban, bankName) => { + const bank = createCustomBank({ name: bankName, currency }); + const { entity, country } = arrangeRefreshFee(currency, iban, bank); + + await service.refreshFee(); + + expect(countryService.getCountryWithSymbol).toHaveBeenCalledWith(iban.substring(0, 2)); + expect(fiatOutputService.selectPayoutBank).toHaveBeenCalledWith( + entity.outputAsset.name, + FiatOutputType.BUY_FIAT, + entity.userData, + false, + country, + ); + expect(transactionHelper.getTxFeeInfos).toHaveBeenCalledWith( + entity.inputAmount, + entity.inputAmount, + entity.cryptoInput.asset, + entity.cryptoInput.asset, + entity.outputAsset, + CryptoPaymentMethod.CRYPTO, + FiatPaymentMethod.BANK, + undefined, + bankName, + entity.user, + ); + + if ([IbanBankName.OLKY, IbanBankName.FRICK].includes(bankName)) { + const bankOut = jest.mocked(transactionHelper.getTxFeeInfos).mock.calls[0][8]; + expect(bankOut).not.toBe(IbanBankName.YAPEAL); + } + }); + + it('fails closed with an undefined bankOut when no payout bank can be resolved', async () => { + const { entity } = arrangeRefreshFee('EUR', 'DE1234567890', undefined); + + await service.refreshFee(); + + expect(transactionHelper.getTxFeeInfos).toHaveBeenCalledWith( + entity.inputAmount, + entity.inputAmount, + entity.cryptoInput.asset, + entity.cryptoInput.asset, + entity.outputAsset, + CryptoPaymentMethod.CRYPTO, + FiatPaymentMethod.BANK, + undefined, + undefined, + entity.user, + ); + const bankOut = jest.mocked(transactionHelper.getTxFeeInfos).mock.calls[0][8]; + expect(bankOut).toBeUndefined(); + expect(bankOut).not.toBe(IbanBankName.YAPEAL); + }); + }); + describe('screenScorechain (Scorechain AML gate)', () => { const call = (entity: any): Promise => (service as any).screenScorechain(entity); 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 6b4dbaaea9..4aa4988a9b 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 @@ -19,7 +19,6 @@ import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enu import { ScorechainDocumentService } from 'src/subdomains/generic/kyc/services/scorechain-document.service'; import { KycStatus, RiskStatus, UserDataStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; -import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; import { FiatOutputType } from 'src/subdomains/supporting/fiat-output/fiat-output.entity'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; @@ -275,6 +274,14 @@ export class BuyFiatPreparationService { const chfPrice = await this.pricingService.getPrice(inputCurrency, PriceCurrency.CHF, PriceValidity.VALID_ONLY); const amountInChf = chfPrice.convert(entity.inputAmount, 2); + const country = await this.countryService.getCountryWithSymbol(entity.sell.iban.substring(0, 2)); + const { bank: payoutBank } = await this.fiatOutputService.selectPayoutBank( + entity.outputAsset.name, + FiatOutputType.BUY_FIAT, + entity.userData, + false, + country, + ); const fee = await this.transactionHelper.getTxFeeInfos( entity.inputAmount, @@ -285,7 +292,17 @@ export class BuyFiatPreparationService { CryptoPaymentMethod.CRYPTO, FiatPaymentMethod.BANK, undefined, - IbanBankName.YAPEAL, + // This prediction and the later FiatOutput bank assignment share the single source of truth, + // FiatOutputService.selectPayoutBank, and agree while the underlying state is unchanged. Active + // virtual IBANs, sender-bank send/sendPriority, and Bank Frick availability are live and mutable; + // because this prediction is neither persisted nor reconciled, bankOut is best-effort and can + // differ if, for example, a virtual IBAN activates or a bank's send/priority changes between calls. + // Fee.verifyForTx in fee.entity.ts matches bank-scoped Fees against its banks array, so an unknown + // or mismatched bankOut can only under-match, never apply a Fee for a bank not actually paying out. + // This is no worse than the previous hardcoded Yapeal failure mode and is strictly better when the + // state is unchanged. Persisting the prediction and reconciling/recomputing the bank-scoped fee + // after real assignment would fix this residual, but is intentionally out of scope given zero exposure. + payoutBank?.name, entity.user, ); diff --git a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts index b1aad5ac11..1ca2bfcb88 100644 --- a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts +++ b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts @@ -15,12 +15,15 @@ import * as processServiceModule from 'src/shared/services/process.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { TestUtil } from 'src/shared/utils/test.util'; import { createCustomBuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/__mocks__/buy-crypto.entity.mock'; +import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { createCustomLiquidityBalance } from 'src/subdomains/core/liquidity-management/__mocks__/liquidity-balance.entity.mock'; import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; import { createCustomBuyFiat } from 'src/subdomains/core/sell-crypto/process/__mocks__/buy-fiat.entity.mock'; import { createCustomSell } from 'src/subdomains/core/sell-crypto/route/__mocks__/sell.entity.mock'; +import { SellRepository } from 'src/subdomains/core/sell-crypto/route/sell.repository'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; import { BankTxOutgoingMatchService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service'; +import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { BankTxRepeatService } from '../../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxReturnService } from '../../bank-tx/bank-tx-return/bank-tx-return.service'; import { createCustomBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; @@ -84,8 +87,13 @@ describe('FiatOutputJobService', () => { imports: [TestSharedModule], providers: [ FiatOutputJobService, + // Real FiatOutputService instance so getPayoutAccount exercises the shared payout-bank selector + // used by fee prediction instead of a hand-duplicated mock. + FiatOutputService, { provide: FiatOutputRepository, useValue: fiatOutputRepo }, { provide: BuyFiatRepository, useValue: createMock() }, + { provide: BuyCryptoRepository, useValue: createMock() }, + { provide: SellRepository, useValue: createMock() }, { provide: BankTxService, useValue: bankTxService }, { provide: BankTxOutgoingMatchService, useValue: bankTxOutgoingMatchService }, { provide: Ep2ReportService, useValue: ep2ReportService }, diff --git a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts index 4674b892f3..0174588779 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -22,19 +22,18 @@ import { BankTxReturnService } from '../bank-tx/bank-tx-return/bank-tx-return.se import { BankTx, BankTxType, BankTxTypeUnassigned } from '../bank-tx/bank-tx/entities/bank-tx.entity'; import { BankTxService } from '../bank-tx/bank-tx/services/bank-tx.service'; import { BankTxOutgoingMatchService } from '../bank-tx/bank-tx/services/bank-tx-outgoing-match.service'; -import { Bank } from '../bank/bank/bank.entity'; -import { BankService } from '../bank/bank/bank.service'; import { IbanBankName } from '../bank/bank/dto/bank.dto'; -import { VirtualIbanService } from '../bank/virtual-iban/virtual-iban.service'; import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; import { LogService } from '../log/log.service'; import { Ep2ReportService } from './ep2-report.service'; import { FiatOutputFrickService } from './fiat-output-frick.service'; import { FiatOutput, FiatOutputType } from './fiat-output.entity'; import { FiatOutputRepository } from './fiat-output.repository'; +import { FiatOutputService } from './fiat-output.service'; @Injectable() export class FiatOutputJobService { @@ -47,7 +46,6 @@ export class FiatOutputJobService { private readonly bankTxService: BankTxService, private readonly bankTxOutgoingMatchService: BankTxOutgoingMatchService, private readonly ep2ReportService: Ep2ReportService, - private readonly bankService: BankService, private readonly countryService: CountryService, private readonly assetService: AssetService, private readonly logService: LogService, @@ -56,7 +54,7 @@ export class FiatOutputJobService { private readonly yapealService: YapealService, private readonly olkypayService: OlkypayService, private readonly frickPayoutService: FiatOutputFrickService, - private readonly virtualIbanService: VirtualIbanService, + private readonly fiatOutputService: FiatOutputService, private readonly scryptService: ScryptService, ) {} @@ -141,50 +139,12 @@ export class FiatOutputJobService { }); } - private async getPayoutAccount(entity: FiatOutput, country: Country): Promise<{ accountIban: string; bank: Bank }> { + private async getPayoutAccount( + entity: FiatOutput, + country: Country, + ): Promise<{ accountIban: string | undefined; bank: Bank | undefined }> { const currency = entity.currency ?? entity.bankAccountCurrency; - - // A Frick instant payout is only ever supported for EUR (Bank Frick rejects instant CHF/FOREIGN - // orders outright) - gate on both the capability flag and the currency so an instant CHF output can - // never be assigned to Frick in the first place, rather than failing on every transmit retry. - const isEligibleFrickCandidate = (bank: Bank): boolean => - bank.name !== IbanBankName.FRICK || !entity.isInstant || (bank.sctInst && currency === 'EUR'); - - // use virtual IBAN if existing - if (entity.userData && [FiatOutputType.BUY_FIAT, FiatOutputType.BUY_CRYPTO_FAIL].includes(entity.type)) { - const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(entity.userData, currency); - - if ( - virtualIban?.bank?.send && - isEligibleFrickCandidate(virtualIban.bank) && - virtualIban.bank.isCountryEnabled(country) && - (virtualIban.bank.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()) - ) - return { accountIban: virtualIban.iban, bank: virtualIban.bank }; - } - - // fallback to standard bank account selection - const banks = await this.bankService.getSenderBanks(currency); - const eligibleBanks = banks.filter( - (candidate) => - isEligibleFrickCandidate(candidate) && - candidate.isCountryEnabled(country) && - (candidate.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()), - ); - - // Sender priority (lower wins) is the deterministic tie-breaker between multiple eligible senders for - // the same currency - an operational input (Bank.sendPriority), not a hardcoded bank-name preference. - // A throw is reserved for a genuine priority tie that involves Frick itself, never for a tie between - // two non-Frick incumbents (e.g. Olkypay EUR and Yapeal EUR both send=true at the shared default - // priority): Array.prototype.sort is stable, so when every candidate shares the same priority, the - // pre-existing first-match order is used instead of throwing away an otherwise-workable route. - const sortedBanks = [...eligibleBanks].sort((a, b) => a.sendPriority - b.sendPriority); - const tiedForTop = sortedBanks.filter((candidate) => candidate.sendPriority === sortedBanks[0]?.sendPriority); - if (tiedForTop.length > 1 && tiedForTop.some((candidate) => candidate.name === IbanBankName.FRICK)) - throw new Error(`Ambiguous sender bank priority for ${currency}`); - - const bank = sortedBanks[0]; - return bank ? { accountIban: bank.iban, bank } : { accountIban: undefined, bank: undefined }; + return this.fiatOutputService.selectPayoutBank(currency, entity.type, entity.userData, entity.isInstant, country); } private async assignBankAccount(): Promise { diff --git a/src/subdomains/supporting/fiat-output/fiat-output.service.ts b/src/subdomains/supporting/fiat-output/fiat-output.service.ts index aec84e4183..5b221368d1 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.service.ts @@ -1,10 +1,16 @@ import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Country } from 'src/shared/models/country/country.entity'; import { AmountType, Util } from 'src/shared/utils/util'; import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; import { SellRepository } from 'src/subdomains/core/sell-crypto/route/sell.repository'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; +import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; +import { FiatOutputFrickService } from 'src/subdomains/supporting/fiat-output/fiat-output-frick.service'; import { BankTxRepeatService } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxReturn } from '../bank-tx/bank-tx-return/bank-tx-return.entity'; import { BankTxReturnService } from '../bank-tx/bank-tx-return/bank-tx-return.service'; @@ -29,8 +35,60 @@ export class FiatOutputService { private readonly bankTxRepeatService: BankTxRepeatService, private readonly bankService: BankService, private readonly sellRepo: SellRepository, + private readonly virtualIbanService: VirtualIbanService, + private readonly frickPayoutService: FiatOutputFrickService, ) {} + async selectPayoutBank( + currency: string, + type: FiatOutputType, + userData: UserData | undefined, + isInstant: boolean, + country: Country, + ): Promise<{ accountIban: string | undefined; bank: Bank | undefined }> { + // A Frick instant payout is only ever supported for EUR (Bank Frick rejects instant CHF/FOREIGN + // orders outright) - gate on both the capability flag and the currency so an instant CHF output can + // never be assigned to Frick in the first place, rather than failing on every transmit retry. + const isEligibleFrickCandidate = (bank: Bank): boolean => + bank.name !== IbanBankName.FRICK || !isInstant || (bank.sctInst && currency === 'EUR'); + + // use virtual IBAN if existing + if (userData && [FiatOutputType.BUY_FIAT, FiatOutputType.BUY_CRYPTO_FAIL].includes(type)) { + const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(userData, currency); + + if ( + virtualIban?.bank?.send && + isEligibleFrickCandidate(virtualIban.bank) && + virtualIban.bank.isCountryEnabled(country) && + (virtualIban.bank.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()) + ) + return { accountIban: virtualIban.iban, bank: virtualIban.bank }; + } + + // fallback to standard bank account selection + const banks = await this.bankService.getSenderBanks(currency); + const eligibleBanks = banks.filter( + (candidate) => + isEligibleFrickCandidate(candidate) && + candidate.isCountryEnabled(country) && + (candidate.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()), + ); + + // Sender priority (lower wins) is the deterministic tie-breaker between multiple eligible senders for + // the same currency - an operational input (Bank.sendPriority), not a hardcoded bank-name preference. + // A throw is reserved for a genuine priority tie that involves Frick itself, never for a tie between + // two non-Frick incumbents (e.g. Olkypay EUR and Yapeal EUR both send=true at the shared default + // priority): Array.prototype.sort is stable, so when every candidate shares the same priority, the + // pre-existing first-match order is used instead of throwing away an otherwise-workable route. + const sortedBanks = [...eligibleBanks].sort((a, b) => a.sendPriority - b.sendPriority); + const tiedForTop = sortedBanks.filter((candidate) => candidate.sendPriority === sortedBanks[0]?.sendPriority); + if (tiedForTop.length > 1 && tiedForTop.some((candidate) => candidate.name === IbanBankName.FRICK)) + throw new Error(`Ambiguous sender bank priority for ${currency}`); + + const bank = sortedBanks[0]; + return bank ? { accountIban: bank.iban, bank } : { accountIban: undefined, bank: undefined }; + } + async create(dto: CreateFiatOutputDto): Promise { this.validateRequiredCreditorFields(dto); From 7b6ee96b3612e8f3c8a562323b3773236dc572f7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:06:11 +0200 Subject: [PATCH 15/19] feat(monitoring): add Bank Frick to the bank-balance observer (#4255) The bank-balance monitoring observer aggregated only Olkypay and Yapeal balances, so Bank Frick balance drift was not monitored. Add a getFrick() helper mirroring getYapeal(), gated by frickService.isAvailable() in fetch() and reusing the existing BankFrickService.getBalances() (the same source the liquidity balance adapter already uses). A missing available balance fails loud rather than silently substituting the booked balance. --- .../observers/__tests__/bank.observer.spec.ts | 166 ++++++++++++++++++ .../monitoring/observers/bank.observer.ts | 29 +++ 2 files changed, 195 insertions(+) create mode 100644 src/subdomains/core/monitoring/observers/__tests__/bank.observer.spec.ts diff --git a/src/subdomains/core/monitoring/observers/__tests__/bank.observer.spec.ts b/src/subdomains/core/monitoring/observers/__tests__/bank.observer.spec.ts new file mode 100644 index 0000000000..05e2f8c8c1 --- /dev/null +++ b/src/subdomains/core/monitoring/observers/__tests__/bank.observer.spec.ts @@ -0,0 +1,166 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; +import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; +import { YapealService } from 'src/integration/bank/services/yapeal.service'; +import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; +import { MonitoringService } from 'src/subdomains/core/monitoring/monitoring.service'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { BankObserver } from '../bank.observer'; + +describe('BankObserver', () => { + let observer: BankObserver; + let repos: RepositoryFactory; + let olkypayService: { getBalance: jest.Mock }; + let bankService: { getBankInternal: jest.Mock }; + let yapealService: { isAvailable: jest.Mock; getBalances: jest.Mock }; + let frickService: { isAvailable: jest.Mock; getBalances: jest.Mock }; + let chainableQuery: { + select: jest.Mock; + where: jest.Mock; + getRawOne: jest.Mock; + }; + + beforeAll(() => { + new ConfigService(); + }); + + beforeEach(() => { + chainableQuery = { + select: jest.fn(), + where: jest.fn(), + getRawOne: jest.fn().mockResolvedValue({ dbBalance: 1000 }), + }; + chainableQuery.select.mockReturnValue(chainableQuery); + chainableQuery.where.mockReturnValue(chainableQuery); + + // RepositoryFactory is a concrete class whose nested repositories are plain instance properties, + // not something @golevelup/ts-jest's createMock deep-mocks automatically - build only the surface + // getDbBalance() actually touches. + repos = { + bankTx: { createQueryBuilder: jest.fn().mockReturnValue(chainableQuery) }, + } as unknown as RepositoryFactory; + + olkypayService = { + getBalance: jest.fn(), + }; + bankService = { + getBankInternal: jest.fn(), + }; + yapealService = { + isAvailable: jest.fn().mockReturnValue(false), + getBalances: jest.fn(), + }; + frickService = { + isAvailable: jest.fn().mockReturnValue(false), + getBalances: jest.fn(), + }; + + observer = new BankObserver( + createMock(), + olkypayService as unknown as OlkypayService, + bankService as unknown as BankService, + repos, + yapealService as unknown as YapealService, + frickService as unknown as BankFrickService, + ); + }); + + describe('getFrick', () => { + it('maps a normal FrickBalance with finite availableBalance into a BankData row', async () => { + chainableQuery.getRawOne.mockResolvedValue({ dbBalance: 900.456 }); + frickService.getBalances.mockResolvedValue([ + { + iban: 'LI21088110104933K000E', + currency: 'CHF', + balance: 1200, + availableBalance: 1100.5, + }, + ]); + + const result = await observer['getFrick'](); + + expect(result).toEqual([ + { + name: 'Bank Frick', + currency: 'CHF', + balance: 1100.5, + dbBalance: 900.46, + difference: 1100.5 - 900.46, + }, + ]); + expect(repos.bankTx.createQueryBuilder).toHaveBeenCalledWith('bankTx'); + expect(chainableQuery.where).toHaveBeenCalledWith('bankTx.accountIban = :iban AND bankTx.currency = :currency', { + iban: 'LI21088110104933K000E', + currency: 'CHF', + }); + }); + + it('throws when availableBalance is undefined (not a finite number)', async () => { + frickService.getBalances.mockResolvedValue([ + { + iban: 'LI21088110104933K000E', + currency: 'CHF', + balance: 1200, + availableBalance: undefined, + }, + ]); + + await expect(observer['getFrick']()).rejects.toThrow( + 'Missing available balance for Bank Frick account LI21088110104933K000E', + ); + }); + + it('throws when availableBalance is NaN', async () => { + frickService.getBalances.mockResolvedValue([ + { + iban: 'LI21088110104933K000E', + currency: 'EUR', + balance: 500, + availableBalance: Number.NaN, + }, + ]); + + await expect(observer['getFrick']()).rejects.toThrow( + 'Missing available balance for Bank Frick account LI21088110104933K000E', + ); + }); + }); + + describe('fetch', () => { + it('includes Frick rows in the aggregated output when frickService.isAvailable() is true', async () => { + frickService.isAvailable.mockReturnValue(true); + frickService.getBalances.mockResolvedValue([ + { + iban: 'LI21088110104933K000E', + currency: 'CHF', + balance: 2000, + availableBalance: 1500, + }, + ]); + chainableQuery.getRawOne.mockResolvedValue({ dbBalance: 1400 }); + + const data = await observer.fetch(); + + expect(frickService.getBalances).toHaveBeenCalled(); + expect(data).toEqual([ + { + name: 'Bank Frick', + currency: 'CHF', + balance: 1500, + dbBalance: 1400, + difference: 100, + }, + ]); + }); + + it('does not call frickService.getBalances when frickService.isAvailable() is false', async () => { + frickService.isAvailable.mockReturnValue(false); + + const data = await observer.fetch(); + + expect(frickService.getBalances).not.toHaveBeenCalled(); + expect(data).toEqual([]); + }); + }); +}); diff --git a/src/subdomains/core/monitoring/observers/bank.observer.ts b/src/subdomains/core/monitoring/observers/bank.observer.ts index cf90589038..622854b9ce 100644 --- a/src/subdomains/core/monitoring/observers/bank.observer.ts +++ b/src/subdomains/core/monitoring/observers/bank.observer.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; @@ -32,6 +33,7 @@ export class BankObserver extends MetricObserver { private readonly bankService: BankService, private readonly repos: RepositoryFactory, private readonly yapealService: YapealService, + private readonly frickService: BankFrickService, ) { super(monitoringService, 'bank', 'balance'); } @@ -42,6 +44,7 @@ export class BankObserver extends MetricObserver { if (Config.bank.olkypay.credentials.clientId) data = data.concat(await this.getOlkypay()); if (this.yapealService.isAvailable()) data = data.concat(await this.getYapeal()); + if (this.frickService.isAvailable()) data = data.concat(await this.getFrick()); this.emit(data); return data; @@ -83,6 +86,32 @@ export class BankObserver extends MetricObserver { return yapealBankData; } + private async getFrick(): Promise { + const frickBalances = await this.frickService.getBalances(); + + const frickBankData = []; + for (const balance of frickBalances) { + const dbBalance = await this.getDbBalance(balance.iban, balance.currency); + + // Bank Frick's `available` field is optional in its own account-listing contract. Falling + // back to the booked `balance` would overstate spendable liquidity (it ignores pending + // debits) - exactly the overdraft risk this case exists to close - so a missing available + // balance fails loud instead of silently substituting a different, unsafe number. + if (!Number.isFinite(balance.availableBalance)) + throw new Error(`Missing available balance for Bank Frick account ${balance.iban}`); + + frickBankData.push({ + name: 'Bank Frick', + currency: balance.currency, + balance: balance.availableBalance, + dbBalance: Util.round(dbBalance, 2), + difference: balance.availableBalance - Util.round(dbBalance, 2), + }); + } + + return frickBankData; + } + private async getDbBalance(iban: string, currency: string): Promise { const { dbBalance } = await this.repos.bankTx .createQueryBuilder('bankTx') From 6b2b48dba01a26dd49511471d1940000f4184b22 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:43:16 +0200 Subject: [PATCH 16/19] fix(ledger): scope native-balance check to single-currency transfers (#4268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ledger): scope native-balance check to single-currency transfers checkNativeBalance is meant — per its own docstring — to sanity-check pure same-asset transfers (all legs ASSET/TRANSIT of ONE currency): then Σ native must be 0. It only gated on onlyAssetTransit and then looped per currency, never checking the tx is single-currency. A fee-less cross-asset micro-fill (feeAmountChf rounds to 0 and the base/quote CHF mark residual is within tolerance → no spread/fee leg) collapses to a bare 2-leg all-ASSET tx and was flagged in both currencies, for a per-currency delta that is the trade itself, not an imbalance — 44 of the 46 spurious post-cutover lines. Add the missing single-currency gate to restore the documented scope, and judge the residual in CHF at the group mark instead of natively (mirroring the §7 reconciliation unit-fix): a bare native tolerance flags sub-rappen fiat rounding — a fiat bank leg carries the unrounded output at >2 dp — yet is far too loose for BTC. That removes the remaining 2 buy_fiat lines while still catching a genuine single-currency imbalance, now reported valued in CHF. An unvalued tx falls back to the native tolerance so a real imbalance is never silently passed. Diagnostic-only: bookings and the CHF invariant are unchanged. * fix(ledger): label unvalued native-imbalance residual instead of "0 CHF" On the mark=0 fallback the CHF value is 0 by construction; print "unvalued (mark 0)" so the residual is not mistaken for a negligible valued amount during triage. Diagnostic text only. --- .../__tests__/ledger-booking.service.spec.ts | 81 +++++++++++++++++++ .../services/ledger-booking.service.ts | 38 ++++++--- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts index af3c893dd7..3326b3a94e 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts @@ -43,6 +43,24 @@ describe('LedgerBookingService', () => { type: AccountType.ROUNDING, currency: 'CHF', }); + const usdtAsset = createCustomLedgerAccount({ + id: 12, + name: 'Binance/USDT', + type: AccountType.ASSET, + currency: 'USDT', + }); + const chfBank = createCustomLedgerAccount({ + id: 30, + name: 'Bank/CHF', + type: AccountType.ASSET, + currency: 'CHF', + }); + const chfPayout = createCustomLedgerAccount({ + id: 31, + name: 'TRANSIT/payout/CHF', + type: AccountType.TRANSIT, + currency: 'CHF', + }); beforeEach(async () => { savedLegs = []; @@ -235,6 +253,69 @@ describe('LedgerBookingService', () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('source exchange_tx 6 seq 0')); // names the producer }); + // #4267: a fee-less cross-asset micro-fill (feeAmountChf rounds to 0 and the base↔quote CHF mark residual is + // within tolerance → no spread/fee leg) collapses to a bare 2-leg all-ASSET tx. Its per-currency native delta + // IS the trade (0.00008 BTC vs 5 USDT), never 0; the old per-currency loop flagged BOTH currencies (44 of the + // 46 post-cutover lines). The single-currency gate restores the docstring scope. + it('does NOT flag a fee-less cross-asset trade (two currencies)', async () => { + const logSpy = jest.spyOn((service as any).logger, 'error'); + const legs: LedgerLegInput[] = [ + { account: walletAsset, amount: 0.00008, priceChf: 50000, amountChf: 4 }, + { account: usdtAsset, amount: -5, priceChf: 0.8, amountChf: -4 }, // different currency → outside scope + ]; + + await service.bookTx({ + sourceType: 'exchange_tx', + sourceId: '7', + seq: 0, + bookingDate: new Date('2026-07-17'), + legs, + }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + // #4267: buy_fiat seq3 — the bank ASSET leg carries the unrounded fiat output (>2 dp) while the payout TRANSIT + // leg is the 2-dp owed amount → a sub-rappen native residual. The CHF value balances (amountChfSum = 0); only + // the native amount has sub-cent noise. Valued at mark 1 the residual is 0.00 CHF ≤ the rounding tolerance. + it('does NOT flag sub-rappen fiat rounding on a single-currency CHF settlement', async () => { + const logSpy = jest.spyOn((service as any).logger, 'error'); + const legs: LedgerLegInput[] = [ + { account: chfPayout, amount: 3303.62, priceChf: 1, amountChf: 3303.62 }, + { account: chfBank, amount: -3303.6168978, priceChf: 1, amountChf: -3303.62 }, // unrounded native output + ]; + + await service.bookTx({ + sourceType: 'buy_fiat', + sourceId: '8', + seq: 3, + bookingDate: new Date('2026-07-17'), + legs, + }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + // #4267: a genuine single-currency native imbalance (CHF nets to 0 via divergent marks, but native does not) + // is still flagged — and the residual is now reported valued in CHF at the group mark (§7 unit-fix). + it('flags a material single-currency native imbalance and reports its CHF value', async () => { + const logSpy = jest.spyOn((service as any).logger, 'error'); + const legs: LedgerLegInput[] = [ + { account: exchangeAsset, amount: 2, priceChf: 25000, amountChf: 50000 }, + { account: walletAsset, amount: -1, priceChf: 50000, amountChf: -50000 }, // CHF balances, native = +1 BTC + ]; + + await service.bookTx({ + sourceType: 'exchange_tx', + sourceId: '9', + seq: 0, + bookingDate: new Date('2026-07-17'), + legs, + }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('50000 CHF @ mark 50000')); + }); + it('reverses a tx with inverted legs and the next free seq', async () => { jest.spyOn(dataSource, 'getRepository').mockReturnValue({ createQueryBuilder: () => ({ diff --git a/src/subdomains/core/accounting/services/ledger-booking.service.ts b/src/subdomains/core/accounting/services/ledger-booking.service.ts index c27e72ecc9..e14b5d5e43 100644 --- a/src/subdomains/core/accounting/services/ledger-booking.service.ts +++ b/src/subdomains/core/accounting/services/ledger-booking.service.ts @@ -384,9 +384,15 @@ export class LedgerBookingService { /** * Native balance is corrected per-asset against the feed (§7), NOT enforced per-tx. The only sanity-check - * is the class of pure same-asset transfers (all legs ASSET/TRANSIT of the SAME currency): then Σ amount - * per currency must be 0. A leg on any non-ASSET/TRANSIT account makes the native one-sidedness correct - * (value-boundary booking) → no native check (§2.3 Major R9-2). + * is the class of pure same-asset transfers (all legs ASSET/TRANSIT of ONE currency): then Σ amount must be + * 0. Two things put a tx outside that scope → no native check (§2.3 Major R9-2): a leg on any non-ASSET/TRANSIT + * account (value-boundary booking → native one-sidedness is correct), OR a second currency — a cross-asset + * trade, whose per-currency native delta is the traded amount and thus never 0 (that is the point of the trade). + * + * The residual is judged in CHF at the group mark, not natively (§7 unit-fix): a bare native tolerance flags + * sub-rappen fiat rounding (a fiat bank leg carries the unrounded output at >2 dp) yet is ~52'000× too loose + * for BTC. An unvalued tx (no mark) falls back to the raw native tolerance so a real imbalance is never + * silently passed. */ private checkNativeBalance(legs: LedgerLeg[], input: LedgerTxInput): void { const onlyAssetTransit = legs.every( @@ -395,15 +401,21 @@ export class LedgerBookingService { if (!onlyAssetTransit) return; const byCurrency = Util.groupByAccessor(legs, (leg) => leg.account.currency); - for (const [currency, currencyLegs] of byCurrency.entries()) { - const nativeSum = currencyLegs.reduce((acc, leg) => acc + leg.amount, 0); - if (Math.abs(nativeSum) > NATIVE_BALANCE_TOLERANCE) { - const accounts = currencyLegs.map((leg) => `${leg.account.name} ${leg.amount}`).join(', '); - this.logger.error( - `Ledger same-asset transfer native imbalance for currency ${currency}: ${nativeSum} ` + - `(source ${input.sourceType} ${input.sourceId} seq ${input.seq}; legs: ${accounts}) (programming error)`, - ); - } - } + if (byCurrency.size !== 1) return; // cross-asset trade: per-currency native delta is the trade, not an imbalance + + const [currency, currencyLegs] = [...byCurrency.entries()][0]; + const nativeSum = currencyLegs.reduce((acc, leg) => acc + leg.amount, 0); + if (Math.abs(nativeSum) <= NATIVE_BALANCE_TOLERANCE) return; // conserves natively + + const mark = Math.max(...currencyLegs.map((leg) => Math.abs(leg.priceChf ?? 0))); + const imbalanceChf = Util.round(Math.abs(nativeSum) * mark, 2); + if (mark > 0 && imbalanceChf <= Config.ledger.roundingToleranceCents / 100) return; // sub-cent rounding noise + + const valuation = mark > 0 ? `${imbalanceChf} CHF @ mark ${mark}` : `unvalued (mark 0)`; + const accounts = currencyLegs.map((leg) => `${leg.account.name} ${leg.amount}`).join(', '); + this.logger.error( + `Ledger same-asset transfer native imbalance for currency ${currency}: ${nativeSum} ` + + `(${valuation}; source ${input.sourceType} ${input.sourceId} seq ${input.seq}; legs: ${accounts}) (programming error)`, + ); } } From 8f50faec16c2361358f3bfcac400e194e15d95ed Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:42:48 +0200 Subject: [PATCH 17/19] fix(ledger): recover feedless-crypto cutover owed openings via a widened last-mark lookup (#4275) * fix(ledger): recover feedless-crypto cutover owed openings via a widened last-mark lookup A cutover owed / manual-debt opening whose asset carries no mark in the 2-day preload window (a delisted/feedless crypto) fails loud and wedges the whole cutover in its 5-minute retry loop. Yet the asset was necessarily priced when the <=90d-old owed row was created, so its last finite mark <= snapshot sits within a 90d (buy_crypto) / 365d (manual-debt) window the 2d preload and the 5d getLatestMark bridge do not reach. Add LedgerMarkService.getMarkAtWidened(assetId, asOf, lookbackDays) - read-only, lazy, memoized, reusing the canonical preload -> getMarkAt path - and use it as a `?? fallback` in openBuyCryptoOwed / openManualDebt. The opening is then valued at outputAmount x last-mark and booked as fixed CHF on the EXISTING CHF bucket (LIABILITY/{bucket}, assetId NULL, needsMark false), byte-identical to a priced owed opening, so the unchanged payout_order / bank_tx discharge closes it cent-exact to 0 - no per-asset account, no settlement-code change. A truly-never-priced asset still yields undefined -> fail-loud (deferred, alarmed). * fix(ledger): evict getMarkAtWidened cache on a rejected preload The per-window preload promise was memoized before it resolved and never evicted on failure. A transient DB error on the widened read would cache a rejected promise under the pinned-snapshot key, so every 5-min cron retry re-threw without re-reading -> the cutover would stay wedged until a process restart even after the DB recovered. Evict the key on rejection so the next run re-reads. --- .../__tests__/ledger-cutover.service.spec.ts | 72 +++++++++++++++++++ .../__tests__/ledger-mark.service.spec.ts | 64 +++++++++++++++++ .../services/ledger-cutover.service.ts | 41 ++++++++--- .../services/ledger-mark.service.ts | 29 ++++++++ 4 files changed, 196 insertions(+), 10 deletions(-) diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts index aa0112954e..03d6a8f8d6 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -153,6 +153,8 @@ describe('LedgerCutoverService', () => { jest.spyOn(bankRepo, 'find').mockResolvedValue([]); jest.spyOn(settingService, 'getObj').mockResolvedValue([] as any); jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); + // default: the widened last-mark fallback finds nothing (feedless) → callers stay fail-loud; #4270 tests override + jest.spyOn(markService, 'getMarkAtWidened').mockResolvedValue(undefined); // watermark MAX(id) query builder stub (chainable where/andWhere for the per-consumer settled filters). getRawMany // backs the §6.3 openHoleIds/idsUpToBoundary path; with MAX(id)=0 the boundary is empty so no holes are queried. @@ -966,6 +968,76 @@ describe('LedgerCutoverService', () => { expect(booked.some((b) => b.sourceId === '1557344:buy_crypto-owed:50')).toBe(false); // no zero-opening booked }); + // #4270 — a delisted/feedless buyCrypto-owed asset (no mark in the 2d preload) is recovered via the widened + // last-mark fallback and booked as FIXED CHF on the SAME CHF bucket as a priced owed row, so the cutover proceeds + // and the UNCHANGED forward discharge closes it — no per-asset account, no mark-to-market dependency (avoids the + // #4251 discharge trap). + it('recovers a feedless buyCrypto-owed opening via the widened last-mark fallback (CHF bucket, no per-asset account)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + const widened = jest.spyOn(markService, 'getMarkAtWidened').mockResolvedValue(3); // 2d cache empty → recovered last mark + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) + return Promise.resolve([ + buyCrypto({ id: 52, outputAmount: 2, outputAsset: { id: 999, uniqueName: 'DeFiChain/DFI' } as any }), + ]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(widened).toHaveBeenCalledWith(999, expect.any(Date), 92); // OPEN_ROW_LOOKBACK_DAYS + 2 + const owed = booked.find((b) => b.sourceId === '1557344:buy_crypto-owed:52'); + expect(owed).toBeDefined(); // recovered → opens (no throw, cutover proceeds) + const liab = owed?.legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-owed'); + expect(liab?.amountChf).toBe(-6); // outputAmount 2 × last-mark 3, Cr −6 + expect(liab?.needsMark).toBe(false); + // the #4251 trap: NO per-asset LIABILITY/buyCrypto-owed/{asset} account is ever created + expect(owed?.legs.every((l) => !l.account.name.startsWith('LIABILITY/buyCrypto-owed/'))).toBe(true); + }); + + // #4270 — a live asset with a mark in the 2d full-tick cache is valued from it; the widened fallback is never + // reached, so live-asset valuations stay byte-identical. + it('values a live-asset buyCrypto-owed from the 2d cache without invoking the widened fallback', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest + .spyOn(markService, 'preload') + .mockResolvedValue( + new LedgerMarkCache(new Map([[42, [{ created: new Date('2026-06-01'), priceChf: 30000 }]]])), + ); + const widened = jest.spyOn(markService, 'getMarkAtWidened'); + jest.spyOn(buyCryptoRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) + return Promise.resolve([buyCrypto({ id: 64, outputAmount: 0.5, outputAsset: { id: 42 } as any })]); + return Promise.resolve([]); + }); + + await service.run(); + + expect(booked.some((b) => b.sourceId === '1557344:buy_crypto-owed:64')).toBe(true); + expect(widened).not.toHaveBeenCalled(); // 2d cache hit → fallback not reached + }); + + // #4270 — a feedless manual-debt asset (no snapshot price) is recovered via the widened fallback over a 365d window + // (a standing debt is NOT bounded by the 90d open-row invariant) and booked as fixed CHF on LIABILITY/manual-debt. + it('recovers a feedless manual-debt opening via the widened last-mark fallback (365d window)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); // debt asset 888 absent → no snapshot price + jest + .spyOn(settingService, 'getObj') + .mockImplementation((key: string) => + Promise.resolve(key === 'balanceLogDebtPositions' ? ([{ assetId: 888, value: 10 }] as any) : ([] as any)), + ); + const widened = jest.spyOn(markService, 'getMarkAtWidened').mockResolvedValue(0.5); + + await service.run(); + + expect(widened).toHaveBeenCalledWith(888, expect.any(Date), 365); + const debt = booked.find((b) => b.sourceId === '1557344:manual-debt:888'); + expect(debt).toBeDefined(); + const liab = debt?.legs.find((l) => l.account.name === 'LIABILITY/manual-debt'); + expect(liab?.amountChf).toBe(-5); // value 10 × last-mark 0.5, Cr −5 + expect(liab?.needsMark).toBe(false); + }); + // §6.1 (Major design-accounting): an open BANK_TX_RETURN (chargebackBankTx IS NULL) is opened per-row, CHF-valued // = amount × bankMark, with the marker the post-cutover chargeback consumer resolves → bankTx-return closes to 0. it('opens bankTx-return per row CHF = amount × EUR-mark with the synthetic marker (Major design-accounting)', async () => { diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts index f3c963ac9d..58089da138 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts @@ -1,6 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { TestUtil } from 'src/shared/utils/test.util'; +import { Util } from 'src/shared/utils/util'; import { createCustomLog } from 'src/subdomains/supporting/log/__mocks__/log.entity.mock'; import { Log } from 'src/subdomains/supporting/log/log.entity'; import { LogService } from 'src/subdomains/supporting/log/log.service'; @@ -67,6 +68,69 @@ describe('LedgerMarkService', () => { }); }); + // §5.2 — widened last-mark fallback: recovers a delisted/feedless asset's last finite mark ≤ asOf over a window wider + // than the 2d preload and 5d getLatestMark bridge, memoized per (from, to) so several feedless rows share one read. + describe('getMarkAtWidened (widened last-mark fallback)', () => { + const asOf = new Date('2026-07-16'); + + it('returns the last finite mark ≤ asOf even when it predates the 2d/5d windows (delisted asset)', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([ + financialLog(new Date('2026-05-01'), { '5': { priceChf: 3 } }), // ~76d before asOf: outside 2d/5d, inside 90d + financialLog(new Date('2026-05-10'), { '5': { priceChf: 4 } }), // last finite ≤ asOf + ]); + + expect(await service.getMarkAtWidened(5, asOf, 90)).toBe(4); + }); + + it('reads over the widened lookback window (from = asOf − lookbackDays, dailySample)', async () => { + const spy = jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(new Date('2026-05-10'), { '5': { priceChf: 4 } })]); + + await service.getMarkAtWidened(5, asOf, 90); + + expect(spy).toHaveBeenCalledWith(Util.daysBefore(90, asOf), true); // 90d span > threshold → dailySample + }); + + it('never returns a mark created after asOf', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([ + financialLog(new Date('2026-05-10'), { '5': { priceChf: 4 } }), + financialLog(new Date('2026-07-18'), { '5': { priceChf: 9 } }), // after asOf → excluded + ]); + + expect(await service.getMarkAtWidened(5, asOf, 90)).toBe(4); + }); + + it('returns undefined for a truly-unpriced asset in the window (caller stays fail-closed)', async () => { + jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(new Date('2026-05-10'), { '5': { priceChf: 4 } })]); + + expect(await service.getMarkAtWidened(999, asOf, 90)).toBeUndefined(); + }); + + it('memoizes per window: several assets over the same (asOf, lookbackDays) trigger a single read', async () => { + const spy = jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValue([financialLog(new Date('2026-05-10'), { '5': { priceChf: 4 }, '6': { priceChf: 7 } })]); + + expect(await service.getMarkAtWidened(5, asOf, 90)).toBe(4); + expect(await service.getMarkAtWidened(6, asOf, 90)).toBe(7); // same window → memoized cache, no re-read + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('does not memoize a rejected load — the next call re-reads (transient-failure recovery, not a permanent wedge)', async () => { + const spy = jest + .spyOn(logService, 'getFinancialLogs') + .mockRejectedValueOnce(new Error('transient DB blip')) + .mockResolvedValueOnce([financialLog(new Date('2026-05-10'), { '5': { priceChf: 4 } })]); + + await expect(service.getMarkAtWidened(5, asOf, 90)).rejects.toThrow('transient DB blip'); + expect(await service.getMarkAtWidened(5, asOf, 90)).toBe(4); // rejected promise evicted → re-read, not re-thrown + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + it('returns the priceChf of the latest mark ≤ bookingDate (stage 2)', async () => { jest .spyOn(logService, 'getFinancialLogs') diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index b53c8f3dea..ebaef83cfb 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -62,6 +62,7 @@ const CHF = 'CHF'; const BUY_FIAT_OPENING_QUALIFIERS = ['buy_fiat', 'buy_fiat-owed', 'buy_fiat-paymentLink']; const BUY_CRYPTO_OPENING_QUALIFIERS = ['buy_crypto', 'buy_crypto-owed']; const OPEN_ROW_LOOKBACK_DAYS = 90; // only targeted liabilities from rows created > cutover − 90d (§6.1) +const MANUAL_DEBT_LOOKBACK_DAYS = 365; // a standing manual-debt position is NOT bounded by the 90d open-row invariant // §6.1: unattributed bank_tx credits the LogJob carries as a liability and the forward consumer routes to // LIABILITY/unattributed (bank-tx.consumer.ts GSHEET/PENDING CRDT). NULL-type credits fall in here too (default-unmapped). const UNATTRIBUTED_TYPES = [BankTxType.GSHEET, BankTxType.PENDING, BankTxType.UNKNOWN]; @@ -559,12 +560,24 @@ export class LedgerCutoverService { ) continue; - const mark = row.outputAsset?.id != null ? marks.getMarkAt(row.outputAsset.id, date) : undefined; + // primary: the 2-day full-tick preload cache. Fallback for a delisted/feedless output asset: the last finite mark + // ≤ snapshot over the 90d window the ≤90d-old row's own age guarantees a price in (getMarkAtWidened) — valuing the + // owed opening at a real fixed CHF on the CHF bucket, byte-identical to a priced owed row, so the run proceeds and + // the UNCHANGED forward discharge (payout_order / bank_tx) closes it to 0. A truly-never-priced asset still yields + // undefined → bookReceivedOwedOpening throws (m6 fail-loud): the cutover defers rather than dropping the value. + const outputAssetId = row.outputAsset?.id; + const mark = + outputAssetId != null + ? (marks.getMarkAt(outputAssetId, date) ?? + (await this.markService.getMarkAtWidened(outputAssetId, date, OPEN_ROW_LOOKBACK_DAYS + 2))) + : undefined; + if (mark == null && outputAssetId != null) + this.logger.error( + `Cutover buyCrypto-owed #${row.id}: output asset ${row.outputAsset?.uniqueName ?? outputAssetId} has no ` + + `finite mark within ${OPEN_ROW_LOOKBACK_DAYS + 2}d of the snapshot — deferring the run`, + ); const amountChf = mark != null ? Util.round(row.outputAmount * mark, 2) : undefined; - // feedless outputAsset → amountChf undefined → bookReceivedOwedOpening throws (m6 fail-loud): a CHF owed - // opening booked with native 0 can never be revalued, so a missing mark must abort the cutover run (already-booked - // openings stay committed, the ready flag stays unset, the cron retries), not silently drop the value. await this.bookReceivedOwedOpening( date, `${snapshot.id}:buy_crypto-owed:${row.id}`, @@ -786,14 +799,22 @@ export class LedgerCutoverService { if (!position?.value) continue; const rawPrice = finance.assets[position.assetId]?.priceChf; - const priceChf = Number.isFinite(rawPrice) ? rawPrice : undefined; + const snapshotPriceChf = Number.isFinite(rawPrice) ? rawPrice : undefined; + // fallback for a delisted/feedless debt asset: the last finite mark ≤ snapshot over a 365d window (a standing debt + // is NOT bounded by the 90d open-row invariant). Booked as fixed CHF on the CHF bucket exactly like a priced + // manual-debt (assetId=NULL, needsMark=false) — manual-debt has no forward settlement and is not mtm-revaluable. + // A truly-never-priced asset still yields undefined → bookReceivedOwedOpening throws (m6 fail-loud): the cutover + // defers rather than dropping the CHF value or booking native units on a CHF account. + const priceChf = + snapshotPriceChf ?? + (await this.markService.getMarkAtWidened(position.assetId, snapshotDate, MANUAL_DEBT_LOOKBACK_DAYS)); + if (priceChf == null) + this.logger.error( + `Cutover manual-debt asset #${position.assetId} has no finite mark within ${MANUAL_DEBT_LOOKBACK_DAYS}d of ` + + `the snapshot — deferring the run`, + ); const amountChf = priceChf != null ? Util.round(priceChf * position.value, 2) : undefined; - // feedless asset (no priceChf in the snapshot) → amountChf undefined → bookReceivedOwedOpening throws (m6 - // fail-loud): the manual-debt LIABILITY is CHF-denominated with NO assetId, so the mark-to-market job can NEVER - // revalue it — a missing price must abort the cutover run (already-booked openings are skipped idempotently on - // the retry once the price feed is available), not silently drop the CHF value or book native units on a CHF - // account. await this.bookReceivedOwedOpening( snapshotDate, `${snapshot.id}:manual-debt:${position.assetId}`, diff --git a/src/subdomains/core/accounting/services/ledger-mark.service.ts b/src/subdomains/core/accounting/services/ledger-mark.service.ts index f891a0f286..4c5b2f2a80 100644 --- a/src/subdomains/core/accounting/services/ledger-mark.service.ts +++ b/src/subdomains/core/accounting/services/ledger-mark.service.ts @@ -55,6 +55,10 @@ export class LedgerMarkService { // memoized youngest-mark-per-asset map (≤ now) for the B5 bridge; refreshed at most once per LATEST_MARK_TTL_MS private latestMarks?: { map: Map; loadedAt: number }; + // §5.2 — per-window LedgerMarkCache memo for the widened last-mark fallback (getMarkAtWidened), keyed `${from}:${to}`; + // dedupes the preload when several feedless cutover rows share a window (one pinned snapshot → 1-2 keys, negligible) + private readonly widenedCaches = new Map>(); + /** * §5.2 Major B5 bridge — the youngest available mark for an asset (latest FinancialDataLog priceChf ≤ now), from a * bounded recent-log read. Used ONLY as the documented fallback when the per-batch cache has no mark AT a historical @@ -90,6 +94,31 @@ export class LedgerMarkService { return map; } + /** + * §5.2 — the last finite mark ≤ `asOf` for one asset, over a window WIDENED past the ~2-day preload cache and the + * 5-day getLatestMark bridge. A cutover owed / manual-debt opening whose asset is delisted carries no mark in those + * short windows and would fail loud and wedge the run — yet the (≤90d-old) owed row's asset was necessarily priced + * when it was created, so its last finite priceChf sits within `lookbackDays`. Reuses the canonical preload → + * getMarkAt (binary-search "latest finite priceChf ≤ asOf") path; the per-window cache is memoized so several + * feedless rows sharing a window trigger a single read. Returns undefined ONLY for a truly-unpriced asset → the + * caller stays fail-closed (never a silent native-0). + */ + async getMarkAtWidened(assetId: number, asOf: Date, lookbackDays: number): Promise { + const from = Util.daysBefore(lookbackDays, asOf); + const key = `${from.getTime()}:${asOf.getTime()}`; + let cache = this.widenedCaches.get(key); + if (!cache) { + // do NOT memoize a transient failure: a rejected preload is evicted so the next cron retry re-reads (the widened + // read is a larger, possibly-paginated query than the 2d preload — a blip must not permanently wedge the cutover) + cache = this.preload(from, asOf).catch((e) => { + this.widenedCaches.delete(key); + throw e; + }); + this.widenedCaches.set(key, cache); + } + return (await cache).getMarkAt(assetId, asOf); + } + /** * Bounded preload (§5.2, Hard Constraint #4): always limited by (batchStartDate, to) and maxRows. * Order is fixed — dailySample decision FIRST (avoids loading the full minute-tick), THEN upper-bound From 10ad1b5360212195f5e80e50f967fa8e68873a08 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:41:19 +0200 Subject: [PATCH 18/19] fix(ledger): expire the widened last-mark memo so a still-feedless window self-heals (#4281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ledger): expire the widened last-mark memo so a still-feedless window self-heals getMarkAtWidened (#4275) memoized its per-window preload on the process-lifetime LedgerMarkService with no TTL — only a rejected load was evicted. A *successful* preload whose immutable LedgerMarkCache lacks the asset (a still-feedless/delisted mark) was therefore cached forever. Because the (from,to) key is byte-stable across every 5-min cutover cron retry (the snapshot date is pinned), each retry re-read the same stale-empty cache -> fail-loud -> the cutover wedged until a process restart, defeating the "retry once the feed is back" recovery #4275 documents (asymmetry vs the TTL'd latestMarks bridge). Give widenedCaches the same self-healing TTL as latestMarks: a stale entry (incl. a successful-but-empty one) expires after WIDENED_MARK_TTL_MS so a later retry re-reads and can pick up a backfilled historical mark; within one run (seconds) the memo still dedupes several feedless rows sharing the window. Liveness-only fix — no financial-correctness impact (fail-loud stays safe). Follows up #4275 (#4270). * fix(ledger): address review nits on the widened-mark TTL - guard the rejected-preload eviction by loadedAt so a late-rejecting stale load can never delete a fresher entry a later retry installed (a widened window now gets replaced after the TTL, so unconditional delete-by-key was newly unsafe); net effect was only an extra read, never a misbooking, but tighten it anyway. - make the Date.now spy restore fail-safe via a describe-level afterEach so a failing assertion cannot leak the mock (the jest config has no restoreMocks). No behaviour change on the happy/transient-failure/self-heal paths. --- .../__tests__/ledger-mark.service.spec.ts | 23 ++++++++++ .../services/ledger-mark.service.ts | 42 +++++++++++++------ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts index 58089da138..dfdcbc2df7 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-mark.service.spec.ts @@ -73,6 +73,8 @@ describe('LedgerMarkService', () => { describe('getMarkAtWidened (widened last-mark fallback)', () => { const asOf = new Date('2026-07-16'); + afterEach(() => jest.restoreAllMocks()); // fail-safe restore (esp. the Date.now spy in the TTL test) even on failure + it('returns the last finite mark ≤ asOf even when it predates the 2d/5d windows (delisted asset)', async () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([ financialLog(new Date('2026-05-01'), { '5': { priceChf: 3 } }), // ~76d before asOf: outside 2d/5d, inside 90d @@ -129,6 +131,27 @@ describe('LedgerMarkService', () => { expect(await service.getMarkAtWidened(5, asOf, 90)).toBe(4); // rejected promise evicted → re-read, not re-thrown expect(spy).toHaveBeenCalledTimes(2); }); + + it('expires a still-empty window after the TTL so a later cron retry re-reads and self-heals (no permanent wedge)', async () => { + const nowSpy = jest.spyOn(Date, 'now'); + const t0 = new Date('2026-07-16T00:00:00Z').getTime(); + nowSpy.mockReturnValue(t0); + + const spy = jest + .spyOn(logService, 'getFinancialLogs') + .mockResolvedValueOnce([]) // first widened read: asset still feedless → undefined, must NOT stick forever + .mockResolvedValueOnce([financialLog(new Date('2026-05-10'), { '5': { priceChf: 4 } })]); // feed restored later + + expect(await service.getMarkAtWidened(5, asOf, 90)).toBeUndefined(); + expect(await service.getMarkAtWidened(5, asOf, 90)).toBeUndefined(); // within TTL → memoized empty, no re-read + expect(spy).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(t0 + 5 * 60 * 1000 + 1); // a later cron retry, past the 5-min memo TTL + expect(await service.getMarkAtWidened(5, asOf, 90)).toBe(4); // re-reads and picks up the restored mark + expect(spy).toHaveBeenCalledTimes(2); + + nowSpy.mockRestore(); + }); }); it('returns the priceChf of the latest mark ≤ bookingDate (stage 2)', async () => { diff --git a/src/subdomains/core/accounting/services/ledger-mark.service.ts b/src/subdomains/core/accounting/services/ledger-mark.service.ts index 4c5b2f2a80..9db293abc3 100644 --- a/src/subdomains/core/accounting/services/ledger-mark.service.ts +++ b/src/subdomains/core/accounting/services/ledger-mark.service.ts @@ -14,6 +14,12 @@ interface MarkPoint { // short memoization TTL so a wedge-heavy batch (many rows missing a historical mark) does not re-query the feed per row. const LATEST_MARK_LOOKBACK_DAYS = 5; const LATEST_MARK_TTL_MS = 5 * 60 * 1000; +// same self-healing TTL for the widened last-mark memo (getMarkAtWidened): its (from,to) key is byte-stable across every +// 5-min cutover cron retry (the snapshot date is pinned), so without expiry a once-empty window — a still-feedless asset +// at preload time — would be memoized forever and wedge the cutover past the documented "retry when the feed is back" +// recovery. Expiry lets a later retry re-read and pick up a backfilled historical mark; within one run (seconds) the +// memo still dedupes several feedless rows sharing the window. +const WIDENED_MARK_TTL_MS = LATEST_MARK_TTL_MS; /** * Per-run mark cache (§5.2). Holds `Map` (each list sorted ascending by `created`) @@ -56,8 +62,10 @@ export class LedgerMarkService { private latestMarks?: { map: Map; loadedAt: number }; // §5.2 — per-window LedgerMarkCache memo for the widened last-mark fallback (getMarkAtWidened), keyed `${from}:${to}`; - // dedupes the preload when several feedless cutover rows share a window (one pinned snapshot → 1-2 keys, negligible) - private readonly widenedCaches = new Map>(); + // dedupes the preload when several feedless cutover rows share a window (one pinned snapshot → 1-2 keys, negligible). + // Carries a load timestamp so a still-empty result expires after WIDENED_MARK_TTL_MS (self-heals across cron retries) + // rather than sticking forever on this process-lifetime singleton. + private readonly widenedCaches = new Map; loadedAt: number }>(); /** * §5.2 Major B5 bridge — the youngest available mark for an asset (latest FinancialDataLog priceChf ≤ now), from a @@ -106,17 +114,27 @@ export class LedgerMarkService { async getMarkAtWidened(assetId: number, asOf: Date, lookbackDays: number): Promise { const from = Util.daysBefore(lookbackDays, asOf); const key = `${from.getTime()}:${asOf.getTime()}`; - let cache = this.widenedCaches.get(key); - if (!cache) { - // do NOT memoize a transient failure: a rejected preload is evicted so the next cron retry re-reads (the widened - // read is a larger, possibly-paginated query than the 2d preload — a blip must not permanently wedge the cutover) - cache = this.preload(from, asOf).catch((e) => { - this.widenedCaches.delete(key); - throw e; - }); - this.widenedCaches.set(key, cache); + const now = Date.now(); + + let entry = this.widenedCaches.get(key); + // Expire a stale memo (including a successful-but-still-empty one) after the TTL so a later cron retry re-reads and + // can pick up a backfilled historical mark — the pinned-snapshot key never changes, so a stuck empty result would + // otherwise wedge the cutover forever (asymmetry vs the TTL'd latestMarks bridge). + if (!entry || now - entry.loadedAt >= WIDENED_MARK_TTL_MS) { + // do NOT memoize a transient failure either: a rejected preload is evicted immediately so the next cron retry + // re-reads (the widened read is a larger, possibly-paginated query than the 2d preload — a blip must not wedge). + // Guard the eviction by loadedAt so a late-rejecting stale load never deletes a fresher entry a retry installed. + const loadedAt = now; + entry = { + cache: this.preload(from, asOf).catch((e) => { + if (this.widenedCaches.get(key)?.loadedAt === loadedAt) this.widenedCaches.delete(key); + throw e; + }), + loadedAt, + }; + this.widenedCaches.set(key, entry); } - return (await cache).getMarkAt(assetId, asOf); + return (await entry.cache).getMarkAt(assetId, asOf); } /** From 3408bfd229fd3dbe78758d93bbc7935f48138e41 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:57:07 +0200 Subject: [PATCH 19/19] fix(history): require auth for transaction list and redact public single DTO (#4166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(history): require auth for transaction list and redact public single DTO List and export endpoints no longer accept wallet address as sole credential. Callers must use JWT or CoinTracking API-key headers; userAddress is an optional ownership-scoped filter. Unauthenticated single-tx lookups return a reduced DTO without bank/chargeback targets, fee breakdowns, or external ids so status links keep working without leaking private financial data. * fix(history): drop unused UserService from HistoryService * fix(history): close residual leaks in the public transaction DTO Gate the input and output tx identifiers on their payment method: a fiat leg reuses the same DTO fields for a private bank reference (outputTxId carries bankTx.remittanceInfo on a sell), so an unauthenticated status link handed out the remittance reference of the payout. Strip the chargeback identifiers unconditionally. The hash resolves to the chargeback target on a block explorer, and a chargeback may go to an address other than the input sender, so it can disclose an address no other public field does. Add PublicTransactionReasonMap, an exhaustively typed fail-closed allow list, and only disclose an AML reason it classifies as operational. Hiding the reason alone is not enough: KYC_REQUIRED is produced by nothing but private reasons, so the state discloses the reason on its own - mask it to the neutral sibling the same AML branch returns for any other pending check. Restore instanceof UserData in isAccountSubject. Reading the address getter dereferences the organization relation and throws for organization accounts that do not have it loaded. Drop the silent `?? []` fallback so a missing users relation fails loud instead of quietly dropping the staking history from an export. * fix(history): stop REALUNIT tenant staff getting blanket full-transaction access isStaffFullAccess() lumped UserRole.REALUNIT into the DFX-staff full-access set, so canViewFullTransaction short-circuited to true for any REALUNIT JWT before any ownership check — exposing every customer's private banking/compliance fields (chargeback IBAN, deposit address, fees, external ids) on GET /transaction/single, across the tenant boundary that every other RealUnit route scopes via RealUnitScopeService. REALUNIT is an isolated external tenant, not DFX staff. Restrict isStaffFullAccess to the SUPPORT hierarchy; RealUnit staff now receive the public DTO here (customer-scoped full access, if ever needed, belongs on the RealUnit-scoped routes, not this public status endpoint). Test locks in the denial. --- ...ransaction.controller.history-auth.spec.ts | 187 +++++++++++ .../__tests__/transaction.controller.spec.ts | 8 +- .../history/controllers/history.controller.ts | 24 +- .../controllers/transaction.controller.ts | 101 +++++- .../core/history/dto/history-query.dto.ts | 18 +- src/subdomains/core/history/history.module.ts | 3 +- .../transaction-dto.mapper.public.spec.ts | 310 ++++++++++++++++++ .../history/mappers/transaction-dto.mapper.ts | 91 +++++ .../__tests__/history-access.service.spec.ts | 239 ++++++++++++++ .../__tests__/history.service.auth.spec.ts | 120 +++++++ .../services/history-access.service.ts | 137 ++++++++ .../core/history/services/history.service.ts | 74 +++-- .../supporting/payment/dto/transaction.dto.ts | 46 +++ 13 files changed, 1303 insertions(+), 55 deletions(-) create mode 100644 src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts create mode 100644 src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts create mode 100644 src/subdomains/core/history/services/__tests__/history-access.service.spec.ts create mode 100644 src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts create mode 100644 src/subdomains/core/history/services/history-access.service.ts diff --git a/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts b/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts new file mode 100644 index 0000000000..fd1381475c --- /dev/null +++ b/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts @@ -0,0 +1,187 @@ +import { createMock } from '@golevelup/ts-jest'; +import { UnauthorizedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { TestSharedModule } from 'src/shared/utils/test.shared.module'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.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 { BankTxReturnService } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service'; +import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; +import { createCustomTransaction } from 'src/subdomains/supporting/payment/__mocks__/transaction.entity.mock'; +import { + TransactionDto, + TransactionState, + TransactionType, +} from 'src/subdomains/supporting/payment/dto/transaction.dto'; +import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; +import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; +import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { BuyCryptoWebhookService } from '../../buy-crypto/process/services/buy-crypto-webhook.service'; +import { BuyService } from '../../buy-crypto/routes/buy/buy.service'; +import { BuyFiatService } from '../../sell-crypto/process/services/buy-fiat.service'; +import { TransactionUtilService } from '../../transaction/transaction-util.service'; +import { TransactionController } from '../controllers/transaction.controller'; +import { ExportFormat } from '../dto/history-query.dto'; +import { HistoryAccessService } from '../services/history-access.service'; +import { ExportType, HistoryService } from '../services/history.service'; + +describe('TransactionController history auth', () => { + let controller: TransactionController; + let historyService: jest.Mocked; + let historyAccessService: jest.Mocked; + let transactionService: jest.Mocked; + let buyCryptoWebhookService: jest.Mocked; + + const jwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 1, user: 10, address: '0xAAA' }; + const account = { id: 1 } as UserData; + + beforeEach(async () => { + historyService = createMock(); + historyAccessService = createMock(); + transactionService = createMock(); + buyCryptoWebhookService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + TransactionController, + { provide: HistoryService, useValue: historyService }, + { provide: HistoryAccessService, useValue: historyAccessService }, + { provide: TransactionService, useValue: transactionService }, + { provide: BuyCryptoWebhookService, useValue: buyCryptoWebhookService }, + { provide: BuyFiatService, useValue: createMock() }, + { provide: BankDataService, useValue: createMock() }, + { provide: BankTxService, useValue: createMock() }, + { provide: FiatService, useValue: createMock() }, + { provide: BuyService, useValue: createMock() }, + { provide: BuyCryptoService, useValue: createMock() }, + { provide: TransactionUtilService, useValue: createMock() }, + { provide: UserDataService, useValue: createMock() }, + { provide: BankTxReturnService, useValue: createMock() }, + { provide: TransactionRequestService, useValue: createMock() }, + { provide: BankService, useValue: createMock() }, + { provide: TransactionHelper, useValue: createMock() }, + { provide: SwissQRService, useValue: createMock() }, + { provide: VirtualIbanService, useValue: createMock() }, + TestUtil.provideConfig(), + ], + }).compile(); + + controller = module.get(TransactionController); + }); + + describe('getTransactions', () => { + it('requires auth via HistoryAccessService and loads history for subject', async () => { + historyAccessService.resolveListSubject.mockResolvedValue(account); + historyService.getHistoryForSubject.mockResolvedValue([]); + + const res = {} as any; + await controller.getTransactions(jwt, undefined, undefined, undefined, { format: ExportFormat.JSON }, res); + + expect(historyAccessService.resolveListSubject).toHaveBeenCalledWith({ + jwt, + userAddress: undefined, + apiKey: undefined, + apiSign: undefined, + apiTimestamp: undefined, + }); + expect(historyService.getHistoryForSubject).toHaveBeenCalledWith( + account, + expect.objectContaining({ format: ExportFormat.JSON }), + ExportType.COMPACT, + ); + }); + + it('propagates UnauthorizedException when access is denied', async () => { + historyAccessService.resolveListSubject.mockRejectedValue(new UnauthorizedException('Authentication required')); + + await expect( + controller.getTransactions(undefined, undefined, undefined, undefined, {}, {} as any), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(historyService.getHistoryForSubject).not.toHaveBeenCalled(); + }); + }); + + describe('createCsv / CoinTracking / ChainReport', () => { + it('createCsv authenticates then caches file key', async () => { + historyAccessService.resolveListSubject.mockResolvedValue(account); + const stream = { getStream: () => null } as any; + historyService.getCsvHistoryForSubject.mockResolvedValue(stream); + + const key = await controller.createCsv(jwt, undefined, undefined, undefined, {}); + expect(typeof key).toBe('string'); + expect(key.length).toBeGreaterThan(0); + expect(historyService.getCsvHistoryForSubject).toHaveBeenCalled(); + }); + + it('getCsvCT uses authenticated subject', async () => { + historyAccessService.resolveListSubject.mockResolvedValue(account); + historyService.getHistoryForSubject.mockResolvedValue([]); + const res = { set: jest.fn() } as any; + + await controller.getCsvCT(jwt, 'k', 's', 't', { format: ExportFormat.JSON }, res); + expect(historyAccessService.resolveListSubject).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'k', apiSign: 's', apiTimestamp: 't' }), + ); + }); + + it('getCsvChainReport uses authenticated subject', async () => { + historyAccessService.resolveListSubject.mockResolvedValue(account); + historyService.getHistoryForSubject.mockResolvedValue([]); + const res = { set: jest.fn() } as any; + + await controller.getCsvChainReport(jwt, undefined, undefined, undefined, { format: ExportFormat.JSON }, res); + expect(historyService.getHistoryForSubject).toHaveBeenCalledWith( + account, + expect.anything(), + ExportType.CHAIN_REPORT, + ); + }); + }); + + describe('getSingleTransaction', () => { + const fullDto = Object.assign(new TransactionDto(), { + uid: 'Tabcdefghijklmnop', + type: TransactionType.BUY, + state: TransactionState.COMPLETED, + chargebackTarget: 'CH9300762011623852957', + fees: { total: 1 }, + externalTransactionId: 'secret', + date: new Date(), + }); + + beforeEach(() => { + const tx = createCustomTransaction({ userData: account as any }); + transactionService.getTransactionByUid = jest.fn().mockResolvedValue(tx); + // force path through getTransactionByUid + jest.spyOn(controller as any, 'getTransaction').mockResolvedValue(tx); + jest.spyOn(controller as any, 'getTransactionDto').mockResolvedValue(fullDto); + }); + + it('returns full dto for owner/staff', async () => { + historyAccessService.canViewFullTransaction.mockReturnValue(true); + + const result = await controller.getSingleTransaction(jwt, 'Tabcdefghijklmnop'); + expect(result).toBe(fullDto); + expect(result.chargebackTarget).toBe('CH9300762011623852957'); + }); + + it('returns public dto without private fields for anonymous', async () => { + historyAccessService.canViewFullTransaction.mockReturnValue(false); + + const result = await controller.getSingleTransaction(undefined, 'Tabcdefghijklmnop'); + expect(result.chargebackTarget).toBeUndefined(); + expect((result as TransactionDto).fees).toBeUndefined(); + expect((result as TransactionDto).externalTransactionId).toBeUndefined(); + expect(result.uid).toBe('Tabcdefghijklmnop'); + }); + }); +}); diff --git a/src/subdomains/core/history/__tests__/transaction.controller.spec.ts b/src/subdomains/core/history/__tests__/transaction.controller.spec.ts index 3fc907eb62..eba1391d15 100644 --- a/src/subdomains/core/history/__tests__/transaction.controller.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction.controller.spec.ts @@ -23,20 +23,20 @@ import { CheckStatus } from '../../aml/enums/check-status.enum'; import { createCustomBuyCrypto } from '../../buy-crypto/process/entities/__mocks__/buy-crypto.entity.mock'; import { BuyCryptoWebhookService } from '../../buy-crypto/process/services/buy-crypto-webhook.service'; import { BuyService } from '../../buy-crypto/routes/buy/buy.service'; -import { RefRewardService } from '../../referral/reward/services/ref-reward.service'; import { BuyFiatService } from '../../sell-crypto/process/services/buy-fiat.service'; import { TransactionUtilService } from '../../transaction/transaction-util.service'; import { TransactionController } from '../controllers/transaction.controller'; +import { HistoryAccessService } from '../services/history-access.service'; import { HistoryService } from '../services/history.service'; describe('TransactionController', () => { let controller: TransactionController; let historyService: HistoryService; + let historyAccessService: HistoryAccessService; let transactionService: TransactionService; let buyCryptoWebhookService: BuyCryptoWebhookService; let buyFiatService: BuyFiatService; - let refRewardService: RefRewardService; let bankDataService: BankDataService; let bankTxService: BankTxService; let fiatService: FiatService; @@ -53,10 +53,10 @@ describe('TransactionController', () => { beforeEach(async () => { historyService = createMock(); + historyAccessService = createMock(); transactionService = createMock(); buyCryptoWebhookService = createMock(); buyFiatService = createMock(); - refRewardService = createMock(); bankDataService = createMock(); bankTxService = createMock(); fiatService = createMock(); @@ -76,10 +76,10 @@ describe('TransactionController', () => { providers: [ TransactionController, { provide: HistoryService, useValue: historyService }, + { provide: HistoryAccessService, useValue: historyAccessService }, { provide: TransactionService, useValue: transactionService }, { provide: BuyCryptoWebhookService, useValue: buyCryptoWebhookService }, { provide: BuyFiatService, useValue: buyFiatService }, - { provide: RefRewardService, useValue: refRewardService }, { provide: BankDataService, useValue: bankDataService }, { provide: BankTxService, useValue: bankTxService }, { provide: FiatService, useValue: fiatService }, diff --git a/src/subdomains/core/history/controllers/history.controller.ts b/src/subdomains/core/history/controllers/history.controller.ts index d5119ea6f4..df891b8cc6 100644 --- a/src/subdomains/core/history/controllers/history.controller.ts +++ b/src/subdomains/core/history/controllers/history.controller.ts @@ -8,7 +8,6 @@ import { Post, Query, Res, - Response, StreamableFile, UseGuards, } from '@nestjs/common'; @@ -21,6 +20,7 @@ import { ApiOkResponse, ApiTags, } from '@nestjs/swagger'; +import { Response } from 'express'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { RoleGuard } from 'src/shared/auth/role.guard'; @@ -35,6 +35,7 @@ import { ExportFormat, HistoryQuery, HistoryQueryExportType, HistoryQueryUser } import { TypedHistoryDto } from '../dto/history.dto'; import { ChainReportApiHistoryDto } from '../dto/output/chain-report-history.dto'; import { CoinTrackingApiHistoryDto } from '../dto/output/coin-tracking-history.dto'; +import { HistoryAccessService } from '../services/history-access.service'; import { ExportType, HistoryService } from '../services/history.service'; import { TransactionController } from './transaction.controller'; @@ -46,6 +47,7 @@ export class HistoryController { constructor( private readonly historyService: HistoryService, + private readonly historyAccessService: HistoryAccessService, private readonly transactionController: TransactionController, private readonly userService: UserService, private readonly userDataService: UserDataService, @@ -58,11 +60,16 @@ export class HistoryController { @ApiOkResponse({ type: TypedHistoryDto, isArray: true }) @ApiExcludeEndpoint() async getHistory( + @GetJwt() jwt: JwtPayload, @Query() query: HistoryQueryUser, - @Response({ passthrough: true }) res, + @Res({ passthrough: true }) res: Response, ): Promise { if (!query.format) query.format = ExportFormat.JSON; - return this.transactionController.getHistoryData(query, ExportType.COMPACT, res); + const subject = await this.historyAccessService.resolveListSubject({ + jwt, + userAddress: query.userAddress ?? jwt.address, + }); + return this.transactionController.getHistoryDataForSubject(subject, query, ExportType.COMPACT, res); } @Get(':exportType') @@ -101,8 +108,13 @@ export class HistoryController { @ApiExcludeEndpoint() @ApiCreatedResponse() async createCsv(@GetJwt() jwt: JwtPayload, @Query() query: HistoryQueryExportType): Promise { - const csvFile = await this.historyService.getCsvHistory( - { ...query, userAddress: jwt.address, format: ExportFormat.CSV }, + const subject = await this.historyAccessService.resolveListSubject({ + jwt, + userAddress: jwt.address, + }); + const csvFile = await this.historyService.getCsvHistoryForSubject( + subject, + { ...query, format: ExportFormat.CSV }, query.type, ); const fileKey = Util.randomString(16); @@ -115,7 +127,7 @@ export class HistoryController { @ApiBearerAuth() @ApiOkResponse({ type: StreamableFile }) @ApiExcludeEndpoint() - async getCsv(@Query('key') key: string, @Res({ passthrough: true }) res): Promise { + async getCsv(@Query('key') key: string, @Res({ passthrough: true }) res: Response): Promise { const csvFile = this.files[key]; if (!csvFile) throw new NotFoundException('File not found'); delete this.files[key]; diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index ca1a33f530..315541cbcd 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -5,6 +5,7 @@ import { Controller, ForbiddenException, Get, + Headers, NotFoundException, Param, ParseIntPipe, @@ -23,6 +24,7 @@ import { Config } from 'src/config/config'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; import { IpGuard } from 'src/shared/auth/ip.guard'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { OptionalJwtAuthGuard } from 'src/shared/auth/optional.guard'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; @@ -74,7 +76,7 @@ import { RefReward } from '../../referral/reward/ref-reward.entity'; import { BuyFiat } from '../../sell-crypto/process/buy-fiat.entity'; import { BuyFiatService } from '../../sell-crypto/process/services/buy-fiat.service'; import { TransactionUtilService } from '../../transaction/transaction-util.service'; -import { ExportFormat, HistoryQueryUser } from '../dto/history-query.dto'; +import { ExportFormat, HistoryQuery, HistoryQueryUser } from '../dto/history-query.dto'; import { HistoryDto } from '../dto/history.dto'; import { ChainReportCsvHistoryDto } from '../dto/output/chain-report-history.dto'; import { CoinTrackingCsvHistoryDto } from '../dto/output/coin-tracking-history.dto'; @@ -83,6 +85,7 @@ import { BaseRefund } from '../dto/refund-internal.dto'; import { TransactionFilter } from '../dto/transaction-filter.dto'; import { TransactionRefundDto } from '../dto/transaction-refund.dto'; import { TransactionDtoMapper } from '../mappers/transaction-dto.mapper'; +import { HistoryAccessService, HistorySubject } from '../services/history-access.service'; import { ExportType, HistoryService } from '../services/history.service'; @ApiTags('Transaction') @@ -93,6 +96,7 @@ export class TransactionController { constructor( private readonly historyService: HistoryService, + private readonly historyAccessService: HistoryAccessService, private readonly transactionService: TransactionService, private readonly buyCryptoWebhookService: BuyCryptoWebhookService, private readonly buyFiatService: BuyFiatService, @@ -119,23 +123,43 @@ export class TransactionController { } } - // --- OPEN ENDPOINTS --- // + // --- HISTORY LIST / EXPORT (auth required: JWT or CT API key) --- // @Get() + @ApiBearerAuth() + @UseGuards(OptionalJwtAuthGuard) @ApiOkResponse({ type: TransactionDto, isArray: true }) + @ApiOperation({ + description: + 'Transaction history for the authenticated subject (Bearer JWT or DFX-ACCESS-KEY/SIGN/TIMESTAMP). ' + + 'Optional userAddress must belong to the subject.', + }) async getTransactions( + @GetJwt() jwt: JwtPayload | undefined, + @Headers('DFX-ACCESS-KEY') apiKey: string, + @Headers('DFX-ACCESS-SIGN') apiSign: string, + @Headers('DFX-ACCESS-TIMESTAMP') apiTimestamp: string, @Query() query: HistoryQueryUser, @Res({ passthrough: true }) res: Response, ): Promise { if (!query.format) query.format = ExportFormat.JSON; - return this.getHistoryData(query, ExportType.COMPACT, res); + const subject = await this.resolveListSubject(jwt, query.userAddress, apiKey, apiSign, apiTimestamp); + return this.getHistoryDataForSubject(subject, query, ExportType.COMPACT, res); } + /** + * Public status lookup by capability identifier (UID / CKO id / order UID). + * Unauthenticated callers receive a reduced public DTO (no IBAN/fees/external ids). + * Owner or staff JWT receives the full compact DTO. + */ @Get('single') + @UseGuards(OptionalJwtAuthGuard) + @ApiBearerAuth() @ApiOkResponse({ type: TransactionDto }) @ApiQuery({ name: 'uid', description: 'Transaction unique ID', required: false }) @ApiQuery({ name: 'order-uid', description: 'Order unique ID', required: false }) @ApiQuery({ name: 'cko-id', description: 'CKO ID', required: false }) async getSingleTransaction( + @GetJwt() jwt: JwtPayload | undefined, @Query('uid') uid?: string, @Query('order-uid') orderUid?: string, @Query('cko-id') ckoId?: string, @@ -145,21 +169,39 @@ export class TransactionController { const dto = await this.getTransactionDto(tx); if (!dto) throw new NotFoundException('Transaction not found'); - return dto; + if (this.historyAccessService.canViewFullTransaction(jwt, tx)) return dto; + + return TransactionDtoMapper.toPublicDto(dto); } @Put('csv') + @ApiBearerAuth() + @UseGuards(OptionalJwtAuthGuard) @ApiOkResponse() - @ApiOperation({ description: 'Initiate CSV history export' }) - async createCsv(@Query() query: HistoryQueryUser): Promise { - const csvFile = await this.historyService.getCsvHistory({ ...query, format: ExportFormat.CSV }, ExportType.COMPACT); + @ApiOperation({ description: 'Initiate CSV history export (requires JWT or CT API key)' }) + async createCsv( + @GetJwt() jwt: JwtPayload | undefined, + @Headers('DFX-ACCESS-KEY') apiKey: string, + @Headers('DFX-ACCESS-SIGN') apiSign: string, + @Headers('DFX-ACCESS-TIMESTAMP') apiTimestamp: string, + @Query() query: HistoryQueryUser, + ): Promise { + const subject = await this.resolveListSubject(jwt, query.userAddress, apiKey, apiSign, apiTimestamp); + const csvFile = await this.historyService.getCsvHistoryForSubject( + subject, + { ...query, format: ExportFormat.CSV }, + ExportType.COMPACT, + ); return this.cacheCsv(csvFile); } @Get('csv') @ApiOkResponse({ type: StreamableFile }) - @ApiOperation({ description: 'Get initiated CSV history export' }) + @ApiOperation({ + description: + 'Download a previously initiated CSV export by one-time key. Key is only issued after authenticated createCsv.', + }) async getCsv(@Query('key') key: string, @Res({ passthrough: true }) res: Response): Promise { const csvFile = this.files[key]; if (!csvFile) throw new NotFoundException('File not found'); @@ -171,25 +213,39 @@ export class TransactionController { } @Get('CoinTracking') + @ApiBearerAuth() + @UseGuards(OptionalJwtAuthGuard) @ApiOkResponse({ type: CoinTrackingCsvHistoryDto, isArray: true }) @ApiExcludeEndpoint() async getCsvCT( + @GetJwt() jwt: JwtPayload | undefined, + @Headers('DFX-ACCESS-KEY') apiKey: string, + @Headers('DFX-ACCESS-SIGN') apiSign: string, + @Headers('DFX-ACCESS-TIMESTAMP') apiTimestamp: string, @Query() query: HistoryQueryUser, @Res({ passthrough: true }) res: Response, ): Promise { if (!query.format) query.format = ExportFormat.CSV; - return this.getHistoryData(query, ExportType.COIN_TRACKING, res); + const subject = await this.resolveListSubject(jwt, query.userAddress, apiKey, apiSign, apiTimestamp); + return this.getHistoryDataForSubject(subject, query, ExportType.COIN_TRACKING, res); } @Get('ChainReport') + @ApiBearerAuth() + @UseGuards(OptionalJwtAuthGuard) @ApiOkResponse({ type: ChainReportCsvHistoryDto, isArray: true }) @ApiExcludeEndpoint() async getCsvChainReport( + @GetJwt() jwt: JwtPayload | undefined, + @Headers('DFX-ACCESS-KEY') apiKey: string, + @Headers('DFX-ACCESS-SIGN') apiSign: string, + @Headers('DFX-ACCESS-TIMESTAMP') apiTimestamp: string, @Query() query: HistoryQueryUser, @Res({ passthrough: true }) res: Response, ): Promise { if (!query.format) query.format = ExportFormat.CSV; - return this.getHistoryData(query, ExportType.CHAIN_REPORT, res); + const subject = await this.resolveListSubject(jwt, query.userAddress, apiKey, apiSign, apiTimestamp); + return this.getHistoryDataForSubject(subject, query, ExportType.CHAIN_REPORT, res); } // --- AUTHORIZED ENDPOINTS --- // @@ -650,16 +706,33 @@ export class TransactionController { return Util.secondsDiff(refundData.expiryDate) <= 0; } - public async getHistoryData( - query: HistoryQueryUser, + public async getHistoryDataForSubject( + subject: HistorySubject, + query: HistoryQuery, exportType: T, - res: any, + res: Response, ): Promise[] | StreamableFile> { - const tx = await this.historyService.getHistory(query, exportType); + const tx = await this.historyService.getHistoryForSubject(subject, query, exportType); if (query.format === ExportFormat.CSV) this.setCsvResult(res, exportType); return tx; } + private async resolveListSubject( + jwt: JwtPayload | undefined, + userAddress: string | undefined, + apiKey: string | undefined, + apiSign: string | undefined, + apiTimestamp: string | undefined, + ): Promise { + return this.historyAccessService.resolveListSubject({ + jwt, + userAddress, + apiKey, + apiSign, + apiTimestamp, + }); + } + private cacheCsv(csvFile: StreamableFile): string { const fileKey = Util.randomString(16); this.files[fileKey] = csvFile; diff --git a/src/subdomains/core/history/dto/history-query.dto.ts b/src/subdomains/core/history/dto/history-query.dto.ts index 03e5631607..f95a26003f 100644 --- a/src/subdomains/core/history/dto/history-query.dto.ts +++ b/src/subdomains/core/history/dto/history-query.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsDate, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { ExportType } from '../services/history.service'; import { HistoryFilter } from './history-filter.dto'; @@ -50,10 +50,18 @@ export class HistoryQuery extends HistoryFilter { } export class HistoryQueryUser extends HistoryQuery { - @ApiProperty() - @IsNotEmpty() + /** + * Optional wallet filter. When omitted, JWT/API-key subject scope is used + * (account-level history for account tokens, wallet for user tokens). + * When set, the address must belong to the authenticated subject. + */ + @ApiPropertyOptional({ + description: + 'Optional wallet address filter; must belong to the authenticated subject. When omitted, subject scope is used.', + }) + @IsOptional() @IsString() - userAddress: string; + userAddress?: string; } export class HistoryQueryExportType extends HistoryQuery { diff --git a/src/subdomains/core/history/history.module.ts b/src/subdomains/core/history/history.module.ts index b4524fa54b..46a40f56d3 100644 --- a/src/subdomains/core/history/history.module.ts +++ b/src/subdomains/core/history/history.module.ts @@ -13,6 +13,7 @@ import { StakingModule } from '../staking/staking.module'; import { TransactionUtilModule } from '../transaction/transaction-util.module'; import { HistoryController } from './controllers/history.controller'; import { TransactionController } from './controllers/transaction.controller'; +import { HistoryAccessService } from './services/history-access.service'; import { HistoryService } from './services/history.service'; @Module({ @@ -31,7 +32,7 @@ import { HistoryService } from './services/history.service'; BankModule, ], controllers: [HistoryController, TransactionController], - providers: [HistoryService, TransactionController], + providers: [HistoryService, HistoryAccessService, TransactionController], exports: [], }) export class HistoryModule {} diff --git a/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts b/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts new file mode 100644 index 0000000000..e2faee7d53 --- /dev/null +++ b/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts @@ -0,0 +1,310 @@ +import { CryptoPaymentMethod, FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { + TransactionDto, + TransactionReason, + TransactionState, + TransactionType, + UnassignedTransactionDto, +} from 'src/subdomains/supporting/payment/dto/transaction.dto'; +import { PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; +import { TransactionDtoMapper } from '../transaction-dto.mapper'; + +describe('TransactionDtoMapper.toPublicDto', () => { + it('strips private fields from a full TransactionDto', () => { + const full = Object.assign(new TransactionDto(), { + id: 42, + uid: 'Tabcdefghijklmnop', + orderUid: 'Qxyz', + type: TransactionType.BUY, + state: TransactionState.COMPLETED, + reason: undefined, + inputAmount: 100, + inputAsset: 'EUR', + inputAssetId: 1, + inputPaymentMethod: FiatPaymentMethod.BANK, + outputAmount: 0.01, + outputAsset: 'BTC', + outputPaymentMethod: CryptoPaymentMethod.CRYPTO, + outputTxUrl: 'https://explorer/tx/1', + depositAddress: 'bc1qsecret', + chargebackTarget: 'CH9300762011623852957', + chargebackAmount: 50, + chargebackAsset: 'EUR', + chargebackTxId: 'remittance-secret', + chargebackTxUrl: 'https://explorer/chargeback/secret', + fees: { total: 1, dfx: 0.5, rate: 0.01 }, + feeAmount: 1, + feeAsset: 'EUR', + priceSteps: [PriceStep.create('Kraken', 'EUR', 'BTC', 0.00001)], + externalTransactionId: 'partner-secret', + networkStartTx: { txId: 'n1', txUrl: 'u', amount: 1, exchangeRate: 1, asset: 'ETH' }, + exchangeRate: 10000, + date: new Date('2024-01-01'), + } as unknown as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.uid).toBe(full.uid); + expect(pub.id).toBe(42); + expect(pub.state).toBe(TransactionState.COMPLETED); + expect(pub.inputAmount).toBe(100); + expect(pub.outputAmount).toBe(0.01); + expect(pub.exchangeRate).toBe(10000); + expect(pub.outputTxUrl).toBe('https://explorer/tx/1'); + expect(pub.chargebackAmount).toBe(50); + + // stripped + expect(pub.chargebackTarget).toBeUndefined(); + expect(pub.depositAddress).toBeUndefined(); + expect(pub.chargebackTxId).toBeUndefined(); + expect(pub.chargebackTxUrl).toBeUndefined(); + expect(pub.fees).toBeUndefined(); + expect(pub.feeAmount).toBeUndefined(); + expect(pub.priceSteps).toBeUndefined(); + expect(pub.externalTransactionId).toBeUndefined(); + expect(pub.networkStartTx).toBeUndefined(); + }); + + it('strips IBAN from UnassignedTransactionDto chargebackTarget', () => { + const unassigned = Object.assign(new UnassignedTransactionDto(), { + uid: 'Tzzzzzzzzzzzzzzzz', + type: TransactionType.BUY, + state: TransactionState.UNASSIGNED, + inputAmount: 10, + inputAsset: 'CHF', + chargebackTarget: 'DE89370400440532013000', + chargebackAmount: 10, + date: new Date(), + } as UnassignedTransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(unassigned); + + expect(pub.uid).toBe(unassigned.uid); + expect(pub.chargebackTarget).toBeUndefined(); + expect(pub.chargebackAmount).toBe(10); + expect(pub).toBeInstanceOf(UnassignedTransactionDto); + }); + + it('does not mutate the original dto', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tabcdefghijklmnop', + type: TransactionType.SELL, + state: TransactionState.PROCESSING, + chargebackTarget: 'CH9300762011623852957', + fees: { total: 2 }, + date: new Date(), + } as TransactionDto); + + TransactionDtoMapper.toPublicDto(full); + + expect(full.chargebackTarget).toBe('CH9300762011623852957'); + expect(full.fees).toEqual({ total: 2 }); + }); + + it('strips a fiat output transaction identifier', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tselloutputref', + type: TransactionType.SELL, + state: TransactionState.COMPLETED, + reason: undefined, + outputPaymentMethod: FiatPaymentMethod.BANK, + outputTxId: 'DFX Payout 4711', + outputTxUrl: 'https://bank/ref-4711', + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.outputTxId).toBeUndefined(); + expect(pub.outputTxUrl).toBeUndefined(); + }); + + it('preserves a crypto output transaction identifier and URL', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tbuyoutputtxid', + type: TransactionType.BUY, + state: TransactionState.COMPLETED, + reason: undefined, + outputPaymentMethod: CryptoPaymentMethod.CRYPTO, + outputTxId: '0xabc', + outputTxUrl: 'https://explorer/tx/0xabc', + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.outputTxId).toBe('0xabc'); + expect(pub.outputTxUrl).toBe('https://explorer/tx/0xabc'); + }); + + it('preserves only crypto input transaction identifiers', () => { + const cryptoInput = Object.assign(new TransactionDto(), { + uid: 'Tcryptoinputid', + type: TransactionType.SELL, + state: TransactionState.PROCESSING, + reason: undefined, + inputPaymentMethod: CryptoPaymentMethod.CRYPTO, + inputTxId: '0xdef', + inputTxUrl: 'https://explorer/tx/0xdef', + date: new Date('2024-01-01'), + } as TransactionDto); + const fiatInput = Object.assign(new TransactionDto(), { + uid: 'Tfiatinputref', + type: TransactionType.BUY, + state: TransactionState.PROCESSING, + reason: undefined, + inputPaymentMethod: FiatPaymentMethod.BANK, + inputTxId: 'some-bank-ref', + inputTxUrl: 'https://bank/in-ref', + date: new Date('2024-01-01'), + } as TransactionDto); + + const publicCryptoInput = TransactionDtoMapper.toPublicDto(cryptoInput) as TransactionDto; + const publicFiatInput = TransactionDtoMapper.toPublicDto(fiatInput) as TransactionDto; + + expect(publicCryptoInput.inputTxId).toBe('0xdef'); + expect(publicCryptoInput.inputTxUrl).toBe('https://explorer/tx/0xdef'); + expect(publicFiatInput.inputTxId).toBeUndefined(); + expect(publicFiatInput.inputTxUrl).toBeUndefined(); + }); + + it('strips the chargeback identifiers of a crypto input, which would resolve to the chargeback target', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tchargebackcrypto', + type: TransactionType.SELL, + state: TransactionState.FAILED, + reason: null, + inputPaymentMethod: CryptoPaymentMethod.CRYPTO, + chargebackTarget: 'bc1qsecretchargebacktarget', + chargebackTxId: '0xchargeback', + chargebackTxUrl: 'https://explorer/tx/0xchargeback', + chargebackAmount: 25, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.chargebackTxId).toBeUndefined(); + expect(pub.chargebackTxUrl).toBeUndefined(); + expect(pub.chargebackTarget).toBeUndefined(); + expect(pub.chargebackAmount).toBe(25); + }); + + it('strips the chargeback identifiers of a fiat input', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tchargebackfiat', + type: TransactionType.BUY, + state: TransactionState.FAILED, + reason: null, + inputPaymentMethod: FiatPaymentMethod.BANK, + chargebackTarget: 'CH9300762011623852957', + chargebackTxId: 'DFX Chargeback 99', + chargebackTxUrl: 'https://bank/chargeback-99', + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.chargebackTxId).toBeUndefined(); + expect(pub.chargebackTxUrl).toBeUndefined(); + expect(pub.chargebackTarget).toBeUndefined(); + }); + + it.each([ + TransactionReason.SANCTION_SUSPICION, + TransactionReason.FRAUD_SUSPICION, + TransactionReason.KYC_REJECTED, + TransactionReason.USER_DELETED, + // belongs to KycRequiredReason — the whole set stays private so that a visible reason can never + // re-identify the CheckPending state that toPublicDto masks KycRequired to + TransactionReason.INSTANT_PAYMENT, + ])('strips private transaction reason %s', (reason: TransactionReason) => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tprivatereason', + type: TransactionType.BUY, + state: TransactionState.FAILED, + reason, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.reason).toBeUndefined(); + }); + + it.each([TransactionReason.MONTHLY_LIMIT_EXCEEDED, TransactionReason.KYC_DATA_NEEDED])( + 'preserves public transaction reason %s', + (reason: TransactionReason) => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tpublicreason', + type: TransactionType.BUY, + state: TransactionState.FAILED, + reason, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.reason).toBe(reason); + }, + ); + + it.each([TransactionReason.SANCTION_SUSPICION, TransactionReason.FRAUD_SUSPICION, TransactionReason.INSTANT_PAYMENT])( + 'masks the KycRequired state, which only the private reason %s can produce', + (reason: TransactionReason) => { + const suspicious = Object.assign(new TransactionDto(), { + uid: 'Tkycrequired', + type: TransactionType.BUY, + state: TransactionState.KYC_REQUIRED, + reason, + date: new Date('2024-01-01'), + } as TransactionDto); + const manualCheck = Object.assign(new TransactionDto(), { + uid: 'Tcheckpending', + type: TransactionType.BUY, + state: TransactionState.CHECK_PENDING, + reason: null, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pubSuspicious = TransactionDtoMapper.toPublicDto(suspicious) as TransactionDto; + const pubManualCheck = TransactionDtoMapper.toPublicDto(manualCheck) as TransactionDto; + + // a suspicion must be indistinguishable from an ordinary pending manual check + expect(pubSuspicious.state).toBe(TransactionState.CHECK_PENDING); + expect(pubSuspicious.reason).toBeUndefined(); + expect(pubSuspicious.state).toBe(pubManualCheck.state); + expect(pubSuspicious.reason).toBe(pubManualCheck.reason); + }, + ); + + it('leaves any other state untouched', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tlimitexceeded', + type: TransactionType.BUY, + state: TransactionState.LIMIT_EXCEEDED, + reason: TransactionReason.MONTHLY_LIMIT_EXCEEDED, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.state).toBe(TransactionState.LIMIT_EXCEEDED); + expect(pub.reason).toBe(TransactionReason.MONTHLY_LIMIT_EXCEEDED); + }); + + it('normalises a null transaction reason to undefined', () => { + // the production mappers set `reason: null` (not undefined) whenever there is no failure reason + const full = Object.assign(new TransactionDto(), { + uid: 'Tnullreason', + type: TransactionType.BUY, + state: TransactionState.PROCESSING, + reason: null, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.reason).toBeUndefined(); + }); +}); diff --git a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts index 80400f1206..0e952c7b10 100644 --- a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts +++ b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts @@ -10,6 +10,7 @@ import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/t import { KycRequiredReason, LimitExceededReason, + PublicTransactionReasonMap, TransactionDetailDto, TransactionDto, TransactionReason, @@ -320,6 +321,96 @@ export class TransactionDtoMapper { return refRewards.map(TransactionDtoMapper.mapReferralReward); } + /** + * Public status payload for unauthenticated `GET /transaction/single` (capability UID/CKO link). + * Keeps fields needed for the status UI (state, amounts, explorer links) and strips + * private banking/compliance/fee-detail data that is not on-chain public information. + */ + static toPublicDto(dto: TransactionDto | UnassignedTransactionDto): TransactionDto | UnassignedTransactionDto { + // A crypto tx hash is public info (anyone can look it up on an explorer), but the fiat leg reuses + // the very same DTO fields for a private bank reference — outputTxId carries + // bankTx.remittanceInfo, see mapBuyFiatTransaction. So gate both legs on their payment method. + // The output gate is the one that actually strips a remittance reference today; the input gate is + // defense in depth, as inputTxId is only ever filled from a cryptoInput. + const isCryptoInput = dto.inputPaymentMethod === CryptoPaymentMethod.CRYPTO; + + const base: UnassignedTransactionDto = { + id: dto.id, + uid: dto.uid, + orderUid: dto.orderUid, + type: dto.type, + // KYC_REQUIRED is produced only by KycRequiredReason, and every one of those reasons is + // private (see PublicTransactionReasonMap) — so the state alone would disclose the reason we + // just hid. Report the neutral sibling the same AML branch returns for a pending check with + // neither a limit nor a KYC reason: the AML check really is pending, and the case becomes + // indistinguishable from the common manual-check one. Owners and staff still get the true + // state on the full DTO. + state: dto.state === TransactionState.KYC_REQUIRED ? TransactionState.CHECK_PENDING : dto.state, + inputAmount: dto.inputAmount, + inputAsset: dto.inputAsset, + inputAssetId: dto.inputAssetId, + inputChainId: dto.inputChainId, + inputBlockchain: dto.inputBlockchain, + inputEvmChainId: dto.inputEvmChainId, + inputPaymentMethod: dto.inputPaymentMethod, + inputTxId: isCryptoInput ? dto.inputTxId : undefined, + inputTxUrl: isCryptoInput ? dto.inputTxUrl : undefined, + // Never hand out an account or wallet identifier directly. For a crypto input the deposit + // address stays derivable from the retained input tx — accepted, that tx is on-chain public + // anyway. The chargeback target is not: a chargeback may go to an address other than the input + // sender, and its tx hash would resolve to exactly that address on a block explorer, so the + // chargeback identifiers are stripped as well. + depositAddress: undefined, + chargebackTarget: undefined, + chargebackAmount: dto.chargebackAmount ?? undefined, + chargebackAsset: dto.chargebackAsset, + chargebackAssetId: dto.chargebackAssetId, + chargebackTxId: undefined, + chargebackTxUrl: undefined, + chargebackDate: dto.chargebackDate, + date: dto.date, + }; + + if (!('reason' in dto) && !('outputAmount' in dto) && !('fees' in dto)) { + return Object.assign(new UnassignedTransactionDto(), base); + } + + const full = dto as TransactionDto; + + const isCryptoOutput = full.outputPaymentMethod === CryptoPaymentMethod.CRYPTO; + + // Fail-closed: only disclose the AML/compliance reason when PublicTransactionReasonMap classifies + // it as operational/actionable. A suspicion, a rejection decision, or account-state information + // about the account holder stays private. Guard against a nullish reason so we never index the + // map with null/undefined. + const isPublicReason = full.reason != null && PublicTransactionReasonMap[full.reason]; + + const publicFull: TransactionDto = { + ...base, + reason: isPublicReason ? full.reason : undefined, + exchangeRate: full.exchangeRate, + rate: full.rate, + outputAmount: full.outputAmount, + outputAsset: full.outputAsset, + outputAssetId: full.outputAssetId, + outputChainId: full.outputChainId, + outputBlockchain: full.outputBlockchain, + outputEvmChainId: full.outputEvmChainId, + outputPaymentMethod: full.outputPaymentMethod, + outputTxId: isCryptoOutput ? full.outputTxId : undefined, + outputTxUrl: isCryptoOutput ? full.outputTxUrl : undefined, + outputDate: full.outputDate, + priceSteps: undefined, + feeAmount: undefined, + feeAsset: undefined, + fees: undefined, + externalTransactionId: undefined, + networkStartTx: undefined, + }; + + return Object.assign(new TransactionDto(), publicFull); + } + // UnassignedTx static mapUnassignedTransaction(tx: BankTx, currency: Fiat, bankTxReturn?: BankTxReturn): UnassignedTransactionDto { return { diff --git a/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts b/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts new file mode 100644 index 0000000000..a10c60a79f --- /dev/null +++ b/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts @@ -0,0 +1,239 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +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 { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { HistoryAccessService } from '../history-access.service'; + +describe('HistoryAccessService', () => { + let service: HistoryAccessService; + let userService: jest.Mocked; + let userDataService: jest.Mocked; + + const accountUsers = [{ id: 10, address: '0xAAA' } as User, { id: 11, address: '0xBBB' } as User]; + const account = { id: 1, users: accountUsers } as UserData; + + beforeEach(() => { + userService = createMock(); + userDataService = createMock(); + service = new HistoryAccessService(userService, userDataService); + }); + + describe('resolveListSubject', () => { + it('rejects when neither JWT nor API key is provided', async () => { + await expect(service.resolveListSubject({})).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects JWT without account', async () => { + await expect( + service.resolveListSubject({ jwt: { role: UserRole.USER, ip: '1.1.1.1' } as JwtPayload }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns account history for account JWT without address filter', async () => { + userDataService.getUserData.mockResolvedValue(account); + + const subject = await service.resolveListSubject({ + jwt: { role: UserRole.ACCOUNT, ip: '1.1.1.1', account: 1 }, + }); + + expect(subject).toBe(account); + expect(userDataService.getUserData).toHaveBeenCalledWith(1, { users: true }); + }); + + it('scopes JWT to jwt.address when no userAddress query', async () => { + userDataService.getUserData.mockResolvedValue(account); + + const subject = await service.resolveListSubject({ + jwt: { role: UserRole.USER, ip: '1.1.1.1', account: 1, address: '0xaaa', user: 10 }, + }); + + expect(subject).toMatchObject({ id: 10, address: '0xAAA' }); + expect((subject as User).userData).toBe(account); + }); + + it('allows userAddress that belongs to the account (case-insensitive)', async () => { + userDataService.getUserData.mockResolvedValue(account); + + const subject = await service.resolveListSubject({ + jwt: { role: UserRole.USER, ip: '1.1.1.1', account: 1, address: '0xAAA', user: 10 }, + userAddress: '0xbbb', + }); + + expect(subject).toMatchObject({ id: 11, address: '0xBBB' }); + }); + + it('forbids userAddress that does not belong to the account', async () => { + userDataService.getUserData.mockResolvedValue(account); + + await expect( + service.resolveListSubject({ + jwt: { role: UserRole.USER, ip: '1.1.1.1', account: 1, address: '0xAAA', user: 10 }, + userAddress: '0xEVIL', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects when JWT account cannot be loaded', async () => { + userDataService.getUserData.mockResolvedValue(null); + + await expect( + service.resolveListSubject({ + jwt: { role: UserRole.ACCOUNT, ip: '1.1.1.1', account: 99 }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('resolves user API key (suffix 0) without address filter', async () => { + const user = { id: 10, address: '0xAAA', apiKeyCT: 'KEY0' } as User; + userService.checkApiKey.mockResolvedValue(user); + + const subject = await service.resolveListSubject({ + apiKey: 'KEY0', + apiSign: 'sig', + apiTimestamp: new Date().toISOString(), + }); + + expect(subject).toBe(user); + expect(userService.checkApiKey).toHaveBeenCalled(); + }); + + it('forbids address mismatch on user API key', async () => { + const user = { id: 10, address: '0xAAA', apiKeyCT: 'KEY0' } as User; + userService.checkApiKey.mockResolvedValue(user); + + await expect( + service.resolveListSubject({ + apiKey: 'KEY0', + apiSign: 'sig', + apiTimestamp: 't', + userAddress: '0xBBB', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('resolves account API key and optional address filter', async () => { + const ud = { id: 1, users: accountUsers, apiKeyCT: 'ACCT1' } as UserData; + userDataService.checkApiKey.mockResolvedValue(ud); + + const subject = await service.resolveListSubject({ + apiKey: 'ACCT1', + apiSign: 'sig', + apiTimestamp: 't', + userAddress: '0xBBB', + }); + + expect(subject).toMatchObject({ id: 11, address: '0xBBB' }); + }); + + it('forbids address not on account API key', async () => { + const ud = { id: 1, users: accountUsers, apiKeyCT: 'ACCT1' } as UserData; + userDataService.checkApiKey.mockResolvedValue(ud); + + await expect( + service.resolveListSubject({ + apiKey: 'ACCT1', + apiSign: 'sig', + apiTimestamp: 't', + userAddress: '0xZZZ', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('prefers JWT over API key when both present', async () => { + userDataService.getUserData.mockResolvedValue(account); + + await service.resolveListSubject({ + jwt: { role: UserRole.ACCOUNT, ip: '1.1.1.1', account: 1 }, + apiKey: 'KEY0', + apiSign: 's', + apiTimestamp: 't', + }); + + expect(userService.checkApiKey).not.toHaveBeenCalled(); + expect(userDataService.getUserData).toHaveBeenCalled(); + }); + }); + + describe('canViewFullTransaction / isOwner', () => { + const ownerJwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 1, user: 10, address: '0xAAA' }; + const otherJwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 2, user: 20, address: '0xCCC' }; + + it('denies full view without JWT', () => { + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(undefined, tx)).toBe(false); + expect(service.isOwner(undefined, tx)).toBe(false); + }); + + it('allows owner of Transaction via userData.id', () => { + const tx = { userData: { id: 1 } } as Transaction; + expect(service.isOwner(ownerJwt, tx)).toBe(true); + expect(service.canViewFullTransaction(ownerJwt, tx)).toBe(true); + }); + + it('allows owner of TransactionRequest via user.userData.id', () => { + const tx = { user: { userData: { id: 1 } } } as TransactionRequest; + expect(service.isOwner(ownerJwt, tx)).toBe(true); + }); + + it('denies non-owner', () => { + const tx = { userData: { id: 1 } } as Transaction; + expect(service.isOwner(otherJwt, tx)).toBe(false); + expect(service.canViewFullTransaction(otherJwt, tx)).toBe(false); + }); + + it('allows SUPPORT staff full access to any tx', () => { + const staff: JwtPayload = { role: UserRole.SUPPORT, ip: '1.1.1.1', account: 99 }; + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(staff, tx)).toBe(true); + expect(service.isOwner(staff, tx)).toBe(false); + }); + + it('allows COMPLIANCE via SUPPORT hierarchy', () => { + const staff: JwtPayload = { role: UserRole.COMPLIANCE, ip: '1.1.1.1', account: 99 }; + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(staff, tx)).toBe(true); + }); + + it('allows ADMIN via hierarchy', () => { + const staff: JwtPayload = { role: UserRole.ADMIN, ip: '1.1.1.1', account: 99 }; + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(staff, tx)).toBe(true); + }); + + it('denies REALUNIT ownership-independent full access (isolated external tenant, not DFX staff)', () => { + const tenantStaff: JwtPayload = { role: UserRole.REALUNIT, ip: '1.1.1.1', account: 99 }; + const tx = { userData: { id: 1 } } as Transaction; // not owned by the RealUnit tenant account + expect(service.canViewFullTransaction(tenantStaff, tx)).toBe(false); + }); + + it('returns false for missing tx', () => { + expect(service.canViewFullTransaction(ownerJwt, undefined)).toBe(false); + }); + + it('extracts account from Transaction.userData.id via isOwner', () => { + const tx = { userData: { id: 5 } } as Transaction; + const jwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 5 }; + expect(service.isOwner(jwt, tx)).toBe(true); + expect(service.canViewFullTransaction(jwt, tx)).toBe(true); + }); + + it('extracts account from TransactionRequest.user.userData.id via isOwner', () => { + const tx = { user: { userData: { id: 7 } } } as TransactionRequest; + const jwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 7 }; + expect(service.isOwner(jwt, tx)).toBe(true); + expect(service.canViewFullTransaction(jwt, tx)).toBe(true); + }); + + it('denies ownership when tx has no account linkage', () => { + const jwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 1 }; + expect(service.isOwner(jwt, {} as Transaction)).toBe(false); + expect(service.canViewFullTransaction(jwt, {} as Transaction)).toBe(false); + }); + }); +}); diff --git a/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts b/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts new file mode 100644 index 0000000000..40b817f38f --- /dev/null +++ b/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts @@ -0,0 +1,120 @@ +import { createMock } from '@golevelup/ts-jest'; +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { BuyCryptoWebhookService } from '../../../buy-crypto/process/services/buy-crypto-webhook.service'; +import { BuyFiatService } from '../../../sell-crypto/process/services/buy-fiat.service'; +import { StakingService } from '../../../staking/services/staking.service'; +import { ExportFormat } from '../../dto/history-query.dto'; +import { ExportType, HistoryService } from '../history.service'; + +describe('HistoryService auth-facing methods', () => { + let service: HistoryService; + let transactionService: jest.Mocked; + let stakingService: jest.Mocked; + + beforeEach(() => { + transactionService = createMock(); + stakingService = createMock(); + service = new HistoryService( + createMock(), + createMock(), + stakingService, + transactionService, + ); + }); + + it('getHistoryForSubject rejects missing subject', async () => { + await expect( + service.getHistoryForSubject(undefined as unknown as User, { format: ExportFormat.JSON }, ExportType.COMPACT), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('getHistoryForSubject uses account transactions for UserData', async () => { + const account = Object.assign(new UserData(), { id: 5, users: [] }); + transactionService.getTransactionsForAccount.mockResolvedValue([]); + + const result = await service.getHistoryForSubject(account, { format: ExportFormat.JSON }, ExportType.COMPACT); + + expect(transactionService.getTransactionsForAccount).toHaveBeenCalledWith( + 5, + undefined, + undefined, + undefined, + undefined, + ); + expect(result).toEqual([]); + }); + + it('routes an ORGANIZATION account to account-scoped history without the organization relation loaded', async () => { + const account = Object.assign(new UserData(), { id: 5, accountType: AccountType.ORGANIZATION, users: [] }); + transactionService.getTransactionsForAccount.mockResolvedValue([]); + + const result = await service.getHistoryForSubject(account, { format: ExportFormat.JSON }, ExportType.COMPACT); + + expect(transactionService.getTransactionsForAccount).toHaveBeenCalledWith( + 5, + undefined, + undefined, + undefined, + undefined, + ); + expect(result).toEqual([]); + }); + + it('getStakingTransactions maps the account users to userIds for the staking lookup', async () => { + const account = Object.assign(new UserData(), { id: 5, users: [{ id: 9 }, { id: 11 }] }); + transactionService.getTransactionsForAccount.mockResolvedValue([]); + stakingService.getUserInvests.mockResolvedValue({ deposits: [], withdrawals: [] }); + stakingService.getUserStakingRewards.mockResolvedValue([]); + stakingService.getUserStakingRefRewards.mockResolvedValue([]); + + await service.getHistoryForSubject(account, { format: ExportFormat.JSON, staking: true }, ExportType.COMPACT); + + expect(stakingService.getUserInvests).toHaveBeenCalledWith([9, 11], undefined, undefined); + expect(stakingService.getUserStakingRewards).toHaveBeenCalledWith([9, 11], undefined, undefined); + expect(stakingService.getUserStakingRefRewards).toHaveBeenCalledWith([9, 11], undefined, undefined); + }); + + it('fails loud when the users relation of an account subject is not loaded', async () => { + // every path reaching this code loads `users`; if one ever stops doing so, the staking history + // must not be silently dropped from the export + const account = Object.assign(new UserData(), { id: 5 }); + transactionService.getTransactionsForAccount.mockResolvedValue([]); + + await expect( + service.getHistoryForSubject(account, { format: ExportFormat.JSON, staking: true }, ExportType.COMPACT), + ).rejects.toThrow(TypeError); + }); + + it('getJsonHistory uses user transactions for User', async () => { + const user = Object.assign(new User(), { id: 9, address: '0x1' }); + transactionService.getTransactionsForUsers.mockResolvedValue([]); + + const result = await service.getJsonHistory(user, { format: ExportFormat.JSON }, ExportType.COMPACT); + + expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith( + [9], + undefined, + undefined, + undefined, + undefined, + ); + expect(result).toEqual([]); + }); + + it('getHistory always throws UnauthorizedException', async () => { + await expect(service.getHistory({}, ExportType.COMPACT)).rejects.toBeInstanceOf(UnauthorizedException); + await expect(service.getHistory({ userAddress: '0x1' }, ExportType.COMPACT)).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('getCsvHistory always throws UnauthorizedException', async () => { + await expect(service.getCsvHistory({ userAddress: '0x1' }, ExportType.COMPACT)).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); +}); diff --git a/src/subdomains/core/history/services/history-access.service.ts b/src/subdomains/core/history/services/history-access.service.ts new file mode 100644 index 0000000000..a55f976950 --- /dev/null +++ b/src/subdomains/core/history/services/history-access.service.ts @@ -0,0 +1,137 @@ +import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { hasRoleAccess } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +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 { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; + +export type HistorySubject = User | UserData; + +export interface HistoryListCredentials { + jwt?: JwtPayload; + userAddress?: string; + apiKey?: string; + apiSign?: string; + apiTimestamp?: string; +} + +/** + * Resolves who may load transaction history and whether a single-tx response may include + * private fields. List/export endpoints require JWT or CT API-key auth; address filters are + * ownership-checked against the authenticated subject (fail-closed). + */ +@Injectable() +export class HistoryAccessService { + constructor( + private readonly userService: UserService, + private readonly userDataService: UserDataService, + ) {} + + /** + * Authenticate a history list/export call. + * - Bearer JWT (account required) preferred + * - else DFX-ACCESS-KEY + SIGN + TIMESTAMP (CoinTracking-style) + * - `userAddress` is only a scope filter and must belong to the subject + */ + async resolveListSubject(creds: HistoryListCredentials): Promise { + if (creds.jwt?.account != null) { + return this.resolveFromJwt(creds.jwt, creds.userAddress); + } + + if (creds.apiKey && creds.apiSign && creds.apiTimestamp) { + return this.resolveFromApiKey(creds.apiKey, creds.apiSign, creds.apiTimestamp, creds.userAddress); + } + + throw new UnauthorizedException('Authentication required'); + } + + /** + * Full (non-public) single-tx payload is allowed for the owner account or staff that + * already has SUPPORT-or-higher access via the role hierarchy. + */ + canViewFullTransaction(jwt: JwtPayload | undefined, tx: Transaction | TransactionRequest | undefined): boolean { + if (!jwt?.role) return false; + if (this.isStaffFullAccess(jwt.role)) return true; + return this.isOwner(jwt, tx); + } + + isOwner(jwt: JwtPayload | undefined, tx: Transaction | TransactionRequest | undefined): boolean { + if (!jwt?.account || !tx) return false; + const accountId = this.accountIdOf(tx); + return accountId != null && accountId === jwt.account; + } + + private isStaffFullAccess(role: UserRole): boolean { + // DFX staff only: the SUPPORT hierarchy (COMPLIANCE / ADMIN / SUPER_ADMIN via hasRoleAccess). REALUNIT is an + // isolated external tenant, NOT DFX staff — granting it ownership-independent full access here would leak every + // customer's private banking/compliance data across the tenant boundary that its own routes scope via + // RealUnitScopeService. RealUnit access to a customer's transaction must stay customer-scoped, never blanket. + return hasRoleAccess(UserRole.SUPPORT, role); + } + + private accountIdOf(tx: Transaction | TransactionRequest): number | undefined { + // Duck-typed: Transaction has userData; TransactionRequest has user.userData (and sometimes both). + const withUserData = tx as { userData?: { id?: number }; user?: { userData?: { id?: number } } }; + return withUserData.userData?.id ?? withUserData.user?.userData?.id; + } + + private async resolveFromJwt(jwt: JwtPayload, userAddress?: string): Promise { + const account = await this.userDataService.getUserData(jwt.account, { users: true }); + if (!account) throw new UnauthorizedException(); + + const addressFilter = userAddress?.trim() || jwt.address?.trim(); + if (!addressFilter) { + // Account token without address → full account history + return account; + } + + const owned = this.findOwnedUser(account, addressFilter); + if (!owned) throw new ForbiddenException('Address does not belong to this account'); + + owned.userData = account; + return owned; + } + + private async resolveFromApiKey( + apiKey: string, + apiSign: string, + apiTimestamp: string, + userAddress?: string, + ): Promise { + // User keys end with version digit `0` (see ApiKeyService / history.controller); account keys differ. + if (apiKey.endsWith('0')) { + const user = await this.userService.checkApiKey(apiKey, apiSign, apiTimestamp); + if (userAddress?.trim() && !this.addressesEqual(user.address, userAddress.trim())) { + throw new ForbiddenException('Address does not belong to this API key'); + } + return user; + } + + const userData = await this.userDataService.checkApiKey(apiKey, apiSign, apiTimestamp); + if (!userAddress?.trim()) return userData; + + const filter = userAddress.trim(); + const users = userData.users?.length + ? userData.users + : (await this.userDataService.getUserData(userData.id, { users: true }))?.users; + + const owned = users?.find((u) => this.addressesEqual(u.address, filter)); + if (!owned) throw new ForbiddenException('Address does not belong to this API key'); + + owned.userData = userData; + return owned; + } + + private findOwnedUser(account: UserData, address: string): User | undefined { + return account.users?.find((u) => this.addressesEqual(u.address, address)); + } + + private addressesEqual(a?: string, b?: string): boolean { + if (!a || !b) return false; + return a.toLowerCase() === b.toLowerCase(); + } +} diff --git a/src/subdomains/core/history/services/history.service.ts b/src/subdomains/core/history/services/history.service.ts index 0640107633..7724f3f4e7 100644 --- a/src/subdomains/core/history/services/history.service.ts +++ b/src/subdomains/core/history/services/history.service.ts @@ -1,9 +1,8 @@ -import { Injectable, NotFoundException, StreamableFile } from '@nestjs/common'; +import { Injectable, NotFoundException, StreamableFile, UnauthorizedException } from '@nestjs/common'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Util } from 'src/shared/utils/util'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; -import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { Readable } from 'stream'; import { TransactionDto } from '../../../supporting/payment/dto/transaction.dto'; @@ -38,7 +37,6 @@ export enum ExportType { @Injectable() export class HistoryService { constructor( - private readonly userService: UserService, private readonly buyCryptoWebhookService: BuyCryptoWebhookService, private readonly buyFiatService: BuyFiatService, private readonly stakingService: StakingService, @@ -89,18 +87,39 @@ export class HistoryService { return (await this.getCompleteHistoryDto(user, query, exportType)) as HistoryDto[]; } + async getCsvHistoryForSubject( + subject: User | UserData, + query: HistoryQuery, + exportFormat: T, + ): Promise { + return (await this.getHistoryForSubject(subject, query, exportFormat)) as StreamableFile; + } + + /** + * @deprecated Prefer {@link getCsvHistoryForSubject} after authenticating via HistoryAccessService. + * Always rejects — unauthenticated address-based history is not allowed. + */ async getCsvHistory(query: HistoryQueryUser, exportFormat: T): Promise { return (await this.getHistory(query, exportFormat)) as StreamableFile; } - async getHistory( - query: HistoryQueryUser, + async getHistoryForSubject( + subject: User | UserData, + query: HistoryQuery, exportType: T, ): Promise[] | StreamableFile> { - const user = await this.userService.getUserByAddress(query.userAddress); - if (!user) throw new NotFoundException('User not found'); + if (!subject) throw new NotFoundException('User not found'); + return this.getCompleteHistoryDto(subject, query, exportType); + } - return this.getCompleteHistoryDto(user, query, exportType); + /** + * @deprecated Prefer {@link getHistoryForSubject}. Never resolves by address unauthenticated. + */ + async getHistory( + _query: HistoryQueryUser, + _exportType: T, + ): Promise[] | StreamableFile> { + throw new UnauthorizedException('Authentication required'); } private async getCompleteHistoryDto( @@ -116,26 +135,31 @@ export class HistoryService { return query.format === ExportFormat.CSV ? this.getCsv(txArray, exportType) : txArray; } + private isAccountSubject(subject: User | UserData): subject is UserData { + // instanceof, not the `address` getter — UserData.address dereferences the organization + // relation for ORGANIZATION / SOLE_PROPRIETORSHIP accounts and throws when it isn't loaded. + return subject instanceof UserData; + } + private async getHistoryTransactions( user: User | UserData, query: HistoryQuery, ): Promise<{ buyCryptos: BuyCrypto[]; buyFiats: BuyFiat[]; refRewards: RefReward[] }> { - const transactions = - user instanceof UserData - ? await this.transactionService.getTransactionsForAccount( - user.id, - query.from, - query.to, - query.limit, - query.offset, - ) - : await this.transactionService.getTransactionsForUsers( - [user.id], - query.from, - query.to, - query.limit, - query.offset, - ); + const transactions = this.isAccountSubject(user) + ? await this.transactionService.getTransactionsForAccount( + user.id, + query.from, + query.to, + query.limit, + query.offset, + ) + : await this.transactionService.getTransactionsForUsers( + [user.id], + query.from, + query.to, + query.limit, + query.offset, + ); const all = query.buy == null && query.sell == null && query.staking == null && query.ref == null && query.lm == null; @@ -251,7 +275,7 @@ export class HistoryService { query: HistoryQuery, exportType: T, ): Promise[]> { - const userIds = user instanceof UserData ? user.users.map((u) => u.id) : [user.id]; + const userIds = this.isAccountSubject(user) ? user.users.map((u) => u.id) : [user.id]; const stakingInvests = await this.stakingService.getUserInvests(userIds, query.from, query.to); const stakingRewards = await this.stakingService.getUserStakingRewards(userIds, query.from, query.to); diff --git a/src/subdomains/supporting/payment/dto/transaction.dto.ts b/src/subdomains/supporting/payment/dto/transaction.dto.ts index e93d3270cc..93e72c8639 100644 --- a/src/subdomains/supporting/payment/dto/transaction.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction.dto.ts @@ -72,6 +72,52 @@ export const KycRequiredReason = [ export const LimitExceededReason = [TransactionReason.MONTHLY_LIMIT_EXCEEDED, TransactionReason.ANNUAL_LIMIT_EXCEEDED]; +// Fail-closed: this map is typed over every key of TransactionReason, so adding a new reason +// without classifying it here is a compile error. +// `false` = must never reach an unauthenticated caller on the public single-transaction lookup, +// either because the reason itself discloses an AML suspicion, a KYC rejection or an account +// deletion (KYC_REJECTED, FRAUD_SUSPICION, SANCTION_SUSPICION, USER_DELETED), or because +// it belongs to the KycRequiredReason set (INSTANT_PAYMENT). Hiding a reason is only half the job: +// the KYC_REQUIRED state is produced by nothing but that set, so the state alone would give the +// suspicions away — TransactionDtoMapper.toPublicDto therefore masks it to CHECK_PENDING. Keeping +// the whole set private means a visible reason can never re-identify the masked state, and the +// reason gate still holds should the state masking ever be weakened. Keep the two in sync. +// `true` = operational info the user can act on (limits, KYC/verification steps, asset/bank/country +// availability, fees, a mismatching account holder), a neutral processing state, or the +// deliberately opaque UNKNOWN bucket (AmlReason USER_BLOCKED / USER_DATA_SUSPICIOUS and friends map +// there, see TransactionReasonMapper) — safe to disclose on a status link. +export const PublicTransactionReasonMap: { [reason in TransactionReason]: boolean } = { + [TransactionReason.UNKNOWN]: true, + [TransactionReason.MONTHLY_LIMIT_EXCEEDED]: true, + [TransactionReason.ANNUAL_LIMIT_EXCEEDED]: true, + [TransactionReason.ACCOUNT_HOLDER_MISMATCH]: true, + [TransactionReason.KYC_REJECTED]: false, + [TransactionReason.FRAUD_SUSPICION]: false, + [TransactionReason.SANCTION_SUSPICION]: false, + [TransactionReason.MIN_DEPOSIT_NOT_REACHED]: true, + [TransactionReason.ASSET_NOT_AVAILABLE]: true, + [TransactionReason.ASSET_NOT_AVAILABLE_WITH_CHOSEN_BANK]: true, + [TransactionReason.STAKING_DISCONTINUED]: true, + [TransactionReason.BANK_NOT_ALLOWED]: true, + [TransactionReason.PAYMENT_ACCOUNT_NOT_ALLOWED]: true, + [TransactionReason.COUNTRY_NOT_ALLOWED]: true, + [TransactionReason.INSTANT_PAYMENT]: false, + [TransactionReason.FEE_TOO_HIGH]: true, + [TransactionReason.RECEIVER_REJECTED]: true, + [TransactionReason.CHF_ABROAD_NOT_ALLOWED]: true, + [TransactionReason.ASSET_KYC_NEEDED]: true, + [TransactionReason.CARD_NAME_MISMATCH]: true, + [TransactionReason.USER_DELETED]: false, + [TransactionReason.VIDEO_IDENT_NEEDED]: true, + [TransactionReason.MISSING_LIQUIDITY]: true, + [TransactionReason.KYC_DATA_NEEDED]: true, + [TransactionReason.BANK_TX_NEEDED]: true, + [TransactionReason.MERGE_INCOMPLETE]: true, + [TransactionReason.PHONE_VERIFICATION_NEEDED]: true, + [TransactionReason.BANK_RELEASE_PENDING]: true, + [TransactionReason.INPUT_NOT_CONFIRMED]: true, +}; + export const TransactionReasonMapper: { [key in AmlReason]: TransactionReason; } = {