From 878163a4b8b37330ba4a4663a4ba40c86bc81581 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:58:44 +0200 Subject: [PATCH 1/2] Track Scrypt deposit notifications with verified delivery (#4345) * feat(fiat-output): track Scrypt deposit notifications with retry The Scrypt deposit request for completed LiqManagement payouts was sent fire-and-forget from the outgoing bank tx match: a send failure was lost forever and manual admin completion bypassed the notification entirely. Replace the inline call with a state-driven sweep over completed, unnotified payouts (new fiat_output.scryptDepositNotifiedDate column). The sweep only marks a payout as notified once the deposit is visible in the balance transaction cache and re-sends the request hourly while unconfirmed, with a stable ClReqID across retries. Rejected or failed deposits keep alerting via error log instead of being marked. Existing completed payouts are backfilled in the migration so the sweep only covers new completions. * fix(fiat-output): throttle rejected Scrypt deposit alerts and harden migration test Review follow-ups: throttle the rejected/failed alert branch through the existing attempt map so a permanently rejected deposit alerts hourly instead of every minute (deliberately kept in the sweep so it stays visible until resolved), document the in-memory pacing and stable ClReqID idempotency assumption, and extend the migration spec with an entity metadata alignment check plus a real-database up/down test covering the backfill predicate. * fix(fiat-output): separate send and alert pacing for Scrypt deposit sweep A fresh send attempt stamped the shared pacing map and could delay the first rejected/failed alert by up to one retry interval. Track send attempts and alerts in separate maps so a rejection arriving right after a send alerts immediately, clear both entries once a deposit is confirmed, and cover the send-then-reject sequence with a dedicated test. * fix(fiat-output): mark Scrypt deposits only on completed status and retry failed sends The sweep marked any cached status other than rejected/failed as notified, so an intermediate status such as PendingApproval would silently end tracking and a later rejection would never alert. Mark only on Completed and wait on any other status. Also stamp the send pacing map only after a successful send so a failed send is retried on the next sweep instead of after an hour, and extend the fail-closed guard to mirror the full sweep predicate (isComplete, not yet notified). Covered by four new tests plus two additional guard cases. --- ...-AddFiatOutputScryptDepositNotifiedDate.js | 42 ++ src/integration/exchange/dto/scrypt.dto.ts | 7 + .../services/__tests__/scrypt.service.spec.ts | 92 ++++- .../exchange/services/scrypt.service.ts | 14 + src/shared/services/process.service.ts | 1 + .../__tests__/fiat-output-job.service.spec.ts | 358 +++++++++++++++++- ...pt-deposit-notified-date.migration.spec.ts | 152 ++++++++ .../fiat-output/fiat-output-job.service.ts | 97 ++++- .../fiat-output/fiat-output.entity.ts | 3 + 9 files changed, 748 insertions(+), 18 deletions(-) create mode 100644 migration/1784700000001-AddFiatOutputScryptDepositNotifiedDate.js create mode 100644 src/subdomains/supporting/fiat-output/__tests__/scrypt-deposit-notified-date.migration.spec.ts diff --git a/migration/1784700000001-AddFiatOutputScryptDepositNotifiedDate.js b/migration/1784700000001-AddFiatOutputScryptDepositNotifiedDate.js new file mode 100644 index 0000000000..7fea3ec4cd --- /dev/null +++ b/migration/1784700000001-AddFiatOutputScryptDepositNotifiedDate.js @@ -0,0 +1,42 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Adds `fiat_output.scryptDepositNotifiedDate` for the Scrypt deposit notify sweep. + * Existing completed LiqManagement payouts to Scrypt are backfilled so the sweep does + * not re-send historical deposits; the fixed timestamp is an audit marker only + * (treated as done because the sweep starts here — not proven notified at the broker). + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddFiatOutputScryptDepositNotifiedDate1784700000001 { + name = 'AddFiatOutputScryptDepositNotifiedDate1784700000001'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "scryptDepositNotifiedDate" TIMESTAMP`); + // Audit marker only: treated as done because the sweep starts now — not proven notified at the broker. + await queryRunner.query(` + UPDATE "fiat_output" + SET "scryptDepositNotifiedDate" = TIMESTAMP '2026-07-23 12:00:00' + WHERE "isComplete" = true + AND "type" = 'LiqManagement' + AND "name" LIKE '%Scrypt Digital Trading%' + AND "scryptDepositNotifiedDate" IS NULL + `); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "scryptDepositNotifiedDate"`); + } +}; diff --git a/src/integration/exchange/dto/scrypt.dto.ts b/src/integration/exchange/dto/scrypt.dto.ts index 33ad9bc55c..4905bd82a8 100644 --- a/src/integration/exchange/dto/scrypt.dto.ts +++ b/src/integration/exchange/dto/scrypt.dto.ts @@ -51,6 +51,13 @@ export interface ScryptWithdrawStatus { rejectText?: string; } +export interface ScryptDepositStatus { + id: string; + status: ScryptTransactionStatus; + rejectReason?: string; + rejectText?: string; +} + // --- TRADE TYPES --- // export enum ScryptTradeSide { diff --git a/src/integration/exchange/services/__tests__/scrypt.service.spec.ts b/src/integration/exchange/services/__tests__/scrypt.service.spec.ts index a42e2d4ca6..fa37135c86 100644 --- a/src/integration/exchange/services/__tests__/scrypt.service.spec.ts +++ b/src/integration/exchange/services/__tests__/scrypt.service.spec.ts @@ -1,4 +1,9 @@ -import { ScryptOrderStatus, ScryptTransactionStatus } from '../../dto/scrypt.dto'; +import { + ScryptBalanceTransaction, + ScryptOrderStatus, + ScryptTransactionStatus, + ScryptTransactionType, +} from '../../dto/scrypt.dto'; import { ScryptMessageType, ScryptWebSocketConnection } from '../scrypt-websocket-connection'; import { ScryptService } from '../scrypt.service'; @@ -43,6 +48,7 @@ describe('ScryptService', () => { fetchAll: jest.Mock; onReconnect: jest.Mock; subscribeToStream: jest.Mock; + send: jest.Mock; }; beforeEach(async () => { @@ -456,4 +462,88 @@ describe('ScryptService', () => { expect(executionReportCalls).toHaveLength(2); expect(balanceTransactionCalls).toHaveLength(2); }); + + describe('getDepositStatus', () => { + it('returns null on cache miss', () => { + expect(service.getDepositStatus('missing-req-id')).toBeNull(); + }); + + it('returns null when the cached transaction is a withdrawal', () => { + const clReqId = 'withdraw-req'; + (service as any).balanceTransactions.set(clReqId, { + TransactionID: 'tx-w-1', + ClReqID: clReqId, + Currency: 'CHF', + TransactionType: ScryptTransactionType.WITHDRAWAL, + Status: ScryptTransactionStatus.COMPLETED, + Quantity: '100', + } satisfies ScryptBalanceTransaction); + + expect(service.getDepositStatus(clReqId)).toBeNull(); + }); + + it('returns the mapped deposit status for a cached deposit transaction', () => { + const clReqId = 'deposit-req'; + (service as any).balanceTransactions.set(clReqId, { + TransactionID: 'tx-d-1', + ClReqID: clReqId, + Currency: 'CHF', + TransactionType: ScryptTransactionType.DEPOSIT, + Status: ScryptTransactionStatus.COMPLETED, + Quantity: '250.5', + RejectReason: 'reason-code', + RejectText: 'human readable', + } satisfies ScryptBalanceTransaction); + + expect(service.getDepositStatus(clReqId)).toEqual({ + id: 'tx-d-1', + status: ScryptTransactionStatus.COMPLETED, + rejectReason: 'reason-code', + rejectText: 'human readable', + }); + }); + }); + + describe('sendDepositRequest', () => { + it('sends a NewDepositRequest with TxHashes derived from reqId when txHashes is omitted', async () => { + const timeStamp = new Date('2026-07-23T12:00:00.000Z'); + await service.sendDepositRequest({ + currency: 'CHF', + amount: 123.45, + reqId: 'DEPOSIT-99', + timeStamp, + }); + + expect(instance.send).toHaveBeenCalledWith(ScryptMessageType.NEW_DEPOSIT_REQUEST, [ + { + Currency: 'CHF', + ClReqID: 'DEPOSIT-99', + Quantity: '123.45', + TransactTime: '2026-07-23T12:00:00.000Z', + TxHashes: [{ TxHash: 'DEPOSIT-99' }], + }, + ]); + }); + + it('sends a NewDepositRequest with the provided txHashes array', async () => { + const timeStamp = new Date('2026-07-23T13:30:00.000Z'); + await service.sendDepositRequest({ + currency: 'EUR', + amount: 50, + reqId: 'E2E-1', + timeStamp, + txHashes: ['0xabc', '0xdef'], + }); + + expect(instance.send).toHaveBeenCalledWith(ScryptMessageType.NEW_DEPOSIT_REQUEST, [ + { + Currency: 'EUR', + ClReqID: 'E2E-1', + Quantity: '50', + TransactTime: '2026-07-23T13:30:00.000Z', + TxHashes: [{ TxHash: '0xabc' }, { TxHash: '0xdef' }], + }, + ]); + }); + }); }); diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index 298d54ee05..8acdfcbadf 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -9,6 +9,7 @@ import { PricingProvider } from 'src/subdomains/supporting/pricing/services/inte import { ScryptBalance, ScryptBalanceTransaction, + ScryptDepositStatus, ScryptExecutionReport, ScryptMarketDataSnapshot, ScryptOrderBook, @@ -268,6 +269,19 @@ export class ScryptService extends PricingProvider { // --- DEPOSITS --- // + getDepositStatus(clReqId: string): ScryptDepositStatus | null { + const transaction = this.balanceTransactions.get(clReqId); + + if (!transaction || transaction.TransactionType !== ScryptTransactionType.DEPOSIT) return null; + + return { + id: transaction.TransactionID, + status: transaction.Status, + rejectReason: transaction.RejectReason, + rejectText: transaction.RejectText, + }; + } + async sendDepositRequest(params: { currency: string; amount: number; diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index 1999527ea0..f14bc78f27 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -68,6 +68,7 @@ export enum Process { FIAT_OUTPUT_BATCH_ID_UPDATE_JOB = 'FiatOutputBatchIdUpdateJob', FIAT_OUTPUT_TRANSMISSION_CHECK = 'FiatOutputTransmissionCheck', FIAT_OUTPUT_BANK_TX_SEARCH = 'FiatOutputBankTxSearch', + FIAT_OUTPUT_SCRYPT_DEPOSIT_NOTIFY = 'FiatOutputScryptDepositNotify', FIAT_OUTPUT_YAPEAL_TRANSMISSION = 'FiatOutputYapealTransmission', FIAT_OUTPUT_YAPEAL_STATUS_CHECK = 'FiatOutputYapealStatusCheck', FIAT_OUTPUT_OLKYPAY_TRANSMISSION = 'FiatOutputOlkypayTransmission', 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 6c8bfb1f13..66f1994b85 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 @@ -1,11 +1,12 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { In, IsNull, Not } from 'typeorm'; +import { In, IsNull, Like, Not } from 'typeorm'; import { FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; import { BankFrickService } from 'src/integration/bank/services/frick.service'; import { IbanService } from 'src/integration/bank/services/iban.service'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; +import { ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto'; import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { AssetType } from 'src/shared/models/asset/asset.entity'; @@ -39,7 +40,7 @@ import { createCustomCryptoInput } from '../../payin/entities/__mocks__/crypto-i import { createCustomFiatOutput } from '../__mocks__/fiat-output.entity.mock'; import { Ep2ReportService } from '../ep2-report.service'; import { FiatOutputFrickService } from '../fiat-output-frick.service'; -import { FiatOutputJobService } from '../fiat-output-job.service'; +import { FiatOutputJobService, SCRYPT_DEPOSIT_NAME_MARKER } from '../fiat-output-job.service'; import { FiatOutputType } from '../fiat-output.entity'; import { FiatOutputRepository } from '../fiat-output.repository'; @@ -1007,4 +1008,357 @@ describe('FiatOutputJobService', () => { expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(6, { isReadyDate: expect.any(Date) }); }); }); + + describe('notifyScryptDeposits', () => { + function createScryptDepositEntity(overrides: Parameters[0] = {}) { + return createCustomFiatOutput({ + id: 10, + type: FiatOutputType.LIQ_MANAGEMENT, + name: `Payout ${SCRYPT_DEPOSIT_NAME_MARKER}`, + isComplete: true, + currency: 'CHF', + amount: 1500, + endToEndId: 'E2E-SCRYPT-10', + scryptDepositNotifiedDate: null, + ...overrides, + }); + } + + it('does not query when the Scrypt deposit notify process is disabled', async () => { + jest + .spyOn(processServiceModule, 'DisabledProcess') + .mockImplementation((process) => process === processServiceModule.Process.FIAT_OUTPUT_SCRYPT_DEPOSIT_NOTIFY); + + await service['notifyScryptDeposits'](); + + expect(fiatOutputRepo.find).not.toHaveBeenCalled(); + }); + + it('loads completed LiqManagement Scrypt deposits that are not yet notified', async () => { + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([]); + + await service['notifyScryptDeposits'](); + + expect(fiatOutputRepo.find).toHaveBeenCalledWith({ + where: { + type: FiatOutputType.LIQ_MANAGEMENT, + name: Like(`%${SCRYPT_DEPOSIT_NAME_MARKER}%`), + isComplete: true, + scryptDepositNotifiedDate: IsNull(), + }, + }); + }); + + it('marks a COMPLETED deposit as notified without re-sending the deposit request', async () => { + const entity = createScryptDepositEntity(); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue({ + id: 'tx-completed', + status: ScryptTransactionStatus.COMPLETED, + }); + + await service['notifyScryptDeposits'](); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(entity.id, { + scryptDepositNotifiedDate: expect.any(Date), + }); + expect(scryptService.sendDepositRequest).not.toHaveBeenCalled(); + }); + + it.each([ + { + status: ScryptTransactionStatus.REJECTED, + depositStatus: { + id: 'tx-rejected', + status: ScryptTransactionStatus.REJECTED, + rejectText: 'Broker rejected deposit', + }, + expectedReason: 'Broker rejected deposit', + }, + { + status: ScryptTransactionStatus.FAILED, + depositStatus: { + id: 'tx-failed', + status: ScryptTransactionStatus.FAILED, + rejectReason: 'Insufficient details', + }, + expectedReason: 'Insufficient details', + }, + { + status: ScryptTransactionStatus.REJECTED, + depositStatus: { + id: 'tx-unknown', + status: ScryptTransactionStatus.REJECTED, + }, + expectedReason: 'unknown reason', + }, + ])( + 'logs $status deposits with reason "$expectedReason" and does not update or re-send', + async ({ depositStatus, expectedReason }) => { + const entity = createScryptDepositEntity(); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue(depositStatus); + const loggerErrorSpy = jest.spyOn(service['logger'], 'error'); + + await service['notifyScryptDeposits'](); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Scrypt deposit request for fiat output ${entity.id} was ${depositStatus.status}: ${expectedReason}`, + ), + ); + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + expect(scryptService.sendDepositRequest).not.toHaveBeenCalled(); + }, + ); + + it('throttles the Rejected/Failed alert log to once per retry interval, then re-alerts after it elapses', async () => { + const entity = createScryptDepositEntity({ id: 66 }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue({ + id: 'tx-rejected-throttle', + status: ScryptTransactionStatus.REJECTED, + rejectText: 'Broker rejected deposit', + }); + const loggerErrorSpy = jest.spyOn(service['logger'], 'error'); + + await service['notifyScryptDeposits'](); + await service['notifyScryptDeposits'](); + + expect(loggerErrorSpy).toHaveBeenCalledTimes(1); + + (service as any).scryptDepositAlerts.set(entity.id, new Date(Date.now() - 61 * 60 * 1000)); + + await service['notifyScryptDeposits'](); + + expect(loggerErrorSpy).toHaveBeenCalledTimes(2); + }); + + it('alerts immediately when a rejection arrives right after a send attempt', async () => { + const entity = createScryptDepositEntity({ id: 99 }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue(null); + const loggerErrorSpy = jest.spyOn(service['logger'], 'error'); + + await service['notifyScryptDeposits'](); + expect(scryptService.sendDepositRequest).toHaveBeenCalledTimes(1); + expect(loggerErrorSpy).not.toHaveBeenCalled(); + + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue({ + id: 'tx-rejected-after-send', + status: ScryptTransactionStatus.REJECTED, + rejectText: 'Broker rejected deposit', + }); + + await service['notifyScryptDeposits'](); + + expect(loggerErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('sends a deposit request using endToEndId as reqId when no status is known yet', async () => { + const entity = createScryptDepositEntity({ endToEndId: 'E2E-CUSTOM-42', id: 42 }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue(null); + + await service['notifyScryptDeposits'](); + + expect(scryptService.sendDepositRequest).toHaveBeenCalledWith({ + currency: entity.currency, + amount: entity.amount, + reqId: 'E2E-CUSTOM-42', + timeStamp: expect.any(Date), + }); + }); + + it('sends a deposit request using DEPOSIT-{id} when endToEndId is missing', async () => { + const entity = createScryptDepositEntity({ endToEndId: undefined, id: 77 }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue(null); + + await service['notifyScryptDeposits'](); + + expect(scryptService.sendDepositRequest).toHaveBeenCalledWith({ + currency: entity.currency, + amount: entity.amount, + reqId: 'DEPOSIT-77', + timeStamp: expect.any(Date), + }); + }); + + it('deduplicates send attempts within the retry interval and re-sends after it elapses', async () => { + const entity = createScryptDepositEntity({ id: 55 }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue(null); + + await service['notifyScryptDeposits'](); + await service['notifyScryptDeposits'](); + + expect(scryptService.sendDepositRequest).toHaveBeenCalledTimes(1); + + (service as any).scryptDepositSendAttempts.set(entity.id, new Date(Date.now() - 61 * 60 * 1000)); + + await service['notifyScryptDeposits'](); + + expect(scryptService.sendDepositRequest).toHaveBeenCalledTimes(2); + }); + + it('clears the local send and alert entries after a successful COMPLETED status update', async () => { + const entity = createScryptDepositEntity({ id: 88 }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue(null); + + await service['notifyScryptDeposits'](); + expect((service as any).scryptDepositSendAttempts.has(entity.id)).toBe(true); + (service as any).scryptDepositAlerts.set(entity.id, new Date()); + + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue({ + id: 'tx-done', + status: ScryptTransactionStatus.COMPLETED, + }); + + await service['notifyScryptDeposits'](); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(entity.id, { + scryptDepositNotifiedDate: expect.any(Date), + }); + expect((service as any).scryptDepositSendAttempts.has(entity.id)).toBe(false); + expect((service as any).scryptDepositAlerts.has(entity.id)).toBe(false); + }); + + it('isolates errors so a failure on one entity does not block the rest of the sweep', async () => { + const entity1 = createScryptDepositEntity({ id: 1, endToEndId: 'E2E-1' }); + const entity2 = createScryptDepositEntity({ id: 2, endToEndId: 'E2E-2' }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity1, entity2]); + jest.spyOn(scryptService, 'getDepositStatus').mockImplementation((reqId: string) => { + if (reqId === 'E2E-1') throw new Error('status lookup failed'); + return null; + }); + const loggerErrorSpy = jest.spyOn(service['logger'], 'error'); + + await service['notifyScryptDeposits'](); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + `Failed to process Scrypt deposit notification for fiat output ${entity1.id}:`, + expect.any(Error), + ); + expect(scryptService.sendDepositRequest).toHaveBeenCalledWith(expect.objectContaining({ reqId: 'E2E-2' })); + }); + + it('waits on an intermediate status without marking or re-sending', async () => { + const entity = createScryptDepositEntity(); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue({ + id: 'tx-pending', + status: 'PendingApproval' as ScryptTransactionStatus, + }); + const loggerErrorSpy = jest.spyOn(service['logger'], 'error'); + + await service['notifyScryptDeposits'](); + + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + expect(scryptService.sendDepositRequest).not.toHaveBeenCalled(); + expect(loggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('retries the send on the next sweep when sendDepositRequest fails', async () => { + const entity = createScryptDepositEntity(); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue(null); + jest + .spyOn(scryptService, 'sendDepositRequest') + .mockRejectedValueOnce(new Error('send failed')) + .mockResolvedValue(undefined as any); + const loggerErrorSpy = jest.spyOn(service['logger'], 'error'); + + await service['notifyScryptDeposits'](); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + `Failed to process Scrypt deposit notification for fiat output ${entity.id}:`, + expect.any(Error), + ); + expect((service as any).scryptDepositSendAttempts.has(entity.id)).toBe(false); + + await service['notifyScryptDeposits'](); + + expect(scryptService.sendDepositRequest).toHaveBeenCalledTimes(2); + }); + + it('re-alerts a rejected deposit after the interval without ever re-sending', async () => { + const entity = createScryptDepositEntity(); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + jest.spyOn(scryptService, 'getDepositStatus').mockReturnValue({ + id: 'tx-rejected', + status: ScryptTransactionStatus.REJECTED, + rejectText: 'Broker rejected deposit', + }); + const loggerErrorSpy = jest.spyOn(service['logger'], 'error'); + + await service['notifyScryptDeposits'](); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(`Scrypt deposit request for fiat output ${entity.id} was Rejected`), + ); + + (service as any).scryptDepositAlerts.set(entity.id, new Date(Date.now() - 61 * 60 * 1000)); + + await service['notifyScryptDeposits'](); + + expect(loggerErrorSpy).toHaveBeenCalledTimes(2); + expect(loggerErrorSpy).toHaveBeenNthCalledWith( + 2, + expect.stringContaining(`Scrypt deposit request for fiat output ${entity.id} was Rejected`), + ); + expect(scryptService.sendDepositRequest).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'wrong type', + entity: createCustomFiatOutput({ + id: 1, + type: FiatOutputType.BUY_FIAT, + name: `Payout ${SCRYPT_DEPOSIT_NAME_MARKER}`, + isComplete: true, + currency: 'CHF', + amount: 100, + }), + }, + { + label: 'name without marker', + entity: createCustomFiatOutput({ + id: 2, + type: FiatOutputType.LIQ_MANAGEMENT, + name: 'Unrelated counterparty', + isComplete: true, + currency: 'CHF', + amount: 100, + }), + }, + { + label: 'undefined name', + entity: createCustomFiatOutput({ + id: 3, + type: FiatOutputType.LIQ_MANAGEMENT, + name: undefined, + isComplete: true, + currency: 'CHF', + amount: 100, + }), + }, + { + label: 'isComplete false', + entity: createScryptDepositEntity({ isComplete: false }), + }, + { + label: 'already notified', + entity: createScryptDepositEntity({ scryptDepositNotifiedDate: new Date() }), + }, + ])('fail-closed: skips notifyScryptDeposit when $label', async ({ entity }) => { + await service['notifyScryptDeposit'](entity); + + expect(scryptService.getDepositStatus).not.toHaveBeenCalled(); + expect(scryptService.sendDepositRequest).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/subdomains/supporting/fiat-output/__tests__/scrypt-deposit-notified-date.migration.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/scrypt-deposit-notified-date.migration.spec.ts new file mode 100644 index 0000000000..359ed47f31 --- /dev/null +++ b/src/subdomains/supporting/fiat-output/__tests__/scrypt-deposit-notified-date.migration.spec.ts @@ -0,0 +1,152 @@ +import { DataSource, getMetadataArgsStorage, QueryRunner } from 'typeorm'; +import { FiatOutput } from '../fiat-output.entity'; +import { SCRYPT_DEPOSIT_NAME_MARKER } from '../fiat-output-job.service'; + +describe('AddFiatOutputScryptDepositNotifiedDate migration', () => { + it('adds the column, backfills completed Scrypt LiqManagement rows, and sets lock timeout', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Migration = require('../../../../../migration/1784700000001-AddFiatOutputScryptDepositNotifiedDate'); + const queryRunner = { query: jest.fn().mockResolvedValue(undefined) }; + + await new Migration().up(queryRunner); + + const sql = queryRunner.query.mock.calls.map(([statement]) => statement).join('\n'); + expect(sql).toContain('SET LOCAL lock_timeout'); + expect(sql).toContain('ALTER TABLE "fiat_output" ADD "scryptDepositNotifiedDate" TIMESTAMP'); + expect(sql).toContain('2026-07-23 12:00:00'); + expect(sql).toContain(`LIKE '%${SCRYPT_DEPOSIT_NAME_MARKER}%'`); + expect(sql).toContain(`"type" = 'LiqManagement'`); + expect(sql).toContain(`"isComplete" = true`); + expect(sql).toContain(`"scryptDepositNotifiedDate" IS NULL`); + }); + + it('drops the scryptDepositNotifiedDate column on rollback', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Migration = require('../../../../../migration/1784700000001-AddFiatOutputScryptDepositNotifiedDate'); + const queryRunner = { query: jest.fn().mockResolvedValue(undefined) }; + + await new Migration().down(queryRunner); + + const sql = queryRunner.query.mock.calls.map(([statement]) => statement).join('\n'); + expect(sql).toContain('DROP COLUMN "scryptDepositNotifiedDate"'); + }); +}); + +describe('FiatOutput Scrypt deposit notified date column metadata', () => { + it('declares scryptDepositNotifiedDate as a nullable timestamp, aligned with the migration DDL', () => { + const column = getMetadataArgsStorage().columns.find( + (c) => c.target === FiatOutput && c.propertyName === 'scryptDepositNotifiedDate', + ); + + expect(column).toBeDefined(); + expect(column.options.type).toBe('timestamp'); + expect(column.options.nullable).toBe(true); + }); +}); + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'scrypt_deposit_notified_date_spec'; + +let AddFiatOutputScryptDepositNotifiedDate: new () => { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +describeDb('AddFiatOutputScryptDepositNotifiedDate migration (real Postgres)', () => { + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddFiatOutputScryptDepositNotifiedDate = require('../../../../../migration/1784700000001-AddFiatOutputScryptDepositNotifiedDate'); + 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}"`); + await queryRunner.query(` + CREATE TABLE "fiat_output" ( + "id" SERIAL PRIMARY KEY, + "isComplete" boolean NOT NULL DEFAULT FALSE, + "type" varchar(256), + "name" varchar(256) + ) + `); + // Fixture rows in a fixed order: (1) matches every backfill condition -> gets the fixed + // timestamp; (2) wrong type; (3) name without the marker; (4) isComplete = false -> all three + // stay NULL. + await queryRunner.query(` + INSERT INTO "fiat_output" ("isComplete", "type", "name") VALUES + (true, 'LiqManagement', 'Payout ${SCRYPT_DEPOSIT_NAME_MARKER}'), + (true, 'BuyFiat', 'Payout ${SCRYPT_DEPOSIT_NAME_MARKER}'), + (true, 'LiqManagement', 'Unrelated counterparty'), + (false, 'LiqManagement', 'Payout ${SCRYPT_DEPOSIT_NAME_MARKER}') + `); + }); + + 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('adds a nullable timestamp column, backfills only matching rows, and removes the column on rollback', async () => { + const migration = new AddFiatOutputScryptDepositNotifiedDate(); + + await queryRunner.startTransaction(); + await migration.up(queryRunner); + const columnInfo = await getColumnInfo(); + const notifiedDates = await getNotifiedDates(); + await queryRunner.commitTransaction(); + + expect(columnInfo).toEqual({ data_type: 'timestamp without time zone', is_nullable: 'YES' }); + // Formatted as text (not parsed as a JS Date) so the assertion is independent of the test + // runner's local timezone - the migration writes a naive TIMESTAMP literal. + expect(notifiedDates).toEqual(['2026-07-23 12:00:00', null, null, null]); + + await queryRunner.startTransaction(); + await migration.down(queryRunner); + const columnExistsAfterDown = await hasNotifiedDateColumn(); + await queryRunner.commitTransaction(); + + expect(columnExistsAfterDown).toBe(false); + }); + + async function getColumnInfo(): Promise<{ data_type: string; is_nullable: string } | undefined> { + const rows = await queryRunner.query( + `SELECT data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'fiat_output' AND column_name = 'scryptDepositNotifiedDate'`, + [SCHEMA], + ); + return rows[0]; + } + + async function getNotifiedDates(): Promise<(string | null)[]> { + const rows = await queryRunner.query( + `SELECT to_char("scryptDepositNotifiedDate", 'YYYY-MM-DD HH24:MI:SS') AS notified + FROM "fiat_output" ORDER BY "id"`, + ); + return rows.map((r: { notified: string | null }) => r.notified); + } + + async function hasNotifiedDateColumn(): Promise { + const rows = await queryRunner.query( + `SELECT 1 FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'fiat_output' AND column_name = 'scryptDepositNotifiedDate'`, + [SCHEMA], + ); + return rows.length > 0; + } +}); 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 4aeea5f55f..953d557773 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -6,6 +6,7 @@ import { OlkypayOrderStatus } from 'src/integration/bank/dto/olkypay.dto'; import { Pain001Payment } from 'src/integration/bank/services/iso20022.service'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; +import { ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto'; import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; import { AzureStorageService } from 'src/integration/infrastructure/azure-storage.service'; import { AssetType } from 'src/shared/models/asset/asset.entity'; @@ -16,7 +17,7 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { DfxCron } from 'src/shared/utils/cron'; import { Util } from 'src/shared/utils/util'; -import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; +import { FindOptionsWhere, In, IsNull, Like, Not } from 'typeorm'; 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 { BankTx, BankTxType, BankTxTypeUnassigned } from '../bank-tx/bank-tx/entities/bank-tx.entity'; @@ -36,6 +37,9 @@ import { FiatOutput, FiatOutputType } from './fiat-output.entity'; import { FiatOutputRepository } from './fiat-output.repository'; import { FiatOutputService } from './fiat-output.service'; +export const SCRYPT_DEPOSIT_NAME_MARKER = 'Scrypt Digital Trading'; +export const SCRYPT_DEPOSIT_RETRY_INTERVAL_MS = 60 * 60 * 1000; // send and alert cadence while a deposit stays unconfirmed + @Injectable() export class FiatOutputJobService { private readonly logger = new DfxLogger(FiatOutputJobService); @@ -70,6 +74,7 @@ export class FiatOutputJobService { await this.transmitOlkypayPayments(); await this.frickPayoutService.transmitPayments(); await this.searchOutgoingBankTx(); + await this.notifyScryptDeposits(); } @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT }) @@ -574,8 +579,6 @@ export class FiatOutputJobService { await this.fiatOutputRepo.update(entity.id, updateData); - await this.notifyScryptDepositIfApplicable(entity); - if (entity.type === FiatOutputType.BANK_TX_RETURN) await this.bankTxReturnService.updateInternal(entity.bankTxReturn, { chargebackBankTx: bankTx }); @@ -616,19 +619,83 @@ export class FiatOutputJobService { } } - private async notifyScryptDepositIfApplicable(entity: FiatOutput): Promise { - if (entity.type !== FiatOutputType.LIQ_MANAGEMENT) return; - if (!entity.name?.includes('Scrypt Digital Trading')) return; + // Last send / last alert time per fiat output, tracked separately so a fresh send never delays + // the first rejection alert. In-memory only: a restart resets the pacing, which at worst causes + // one extra send — assumed safe because the ClReqID stays stable per entity, so repeated + // requests are deduplicatable on the receiving side. + private readonly scryptDepositSendAttempts = new Map(); + private readonly scryptDepositAlerts = new Map(); - try { - await this.scryptService.sendDepositRequest({ - currency: entity.currency, - amount: entity.amount, - reqId: entity.endToEndId ?? `DEPOSIT-${entity.id}`, - timeStamp: new Date(), - }); - } catch (e) { - this.logger.error(`Failed to send Scrypt deposit request for fiat output ${entity.id}:`, e); + private async notifyScryptDeposits(): Promise { + if (DisabledProcess(Process.FIAT_OUTPUT_SCRYPT_DEPOSIT_NOTIFY)) return; + + const entities = await this.fiatOutputRepo.find({ + where: { + type: FiatOutputType.LIQ_MANAGEMENT, + name: Like(`%${SCRYPT_DEPOSIT_NAME_MARKER}%`), + isComplete: true, + scryptDepositNotifiedDate: IsNull(), + }, + }); + + for (const entity of entities) { + try { + await this.notifyScryptDeposit(entity); + } catch (e) { + this.logger.error(`Failed to process Scrypt deposit notification for fiat output ${entity.id}:`, e); + } } } + + private async notifyScryptDeposit(entity: FiatOutput): Promise { + // fail-closed guard against future query drift: conditions must mirror the sweep query + if ( + entity.type !== FiatOutputType.LIQ_MANAGEMENT || + !entity.name?.includes(SCRYPT_DEPOSIT_NAME_MARKER) || + !entity.isComplete || + entity.scryptDepositNotifiedDate + ) { + return; + } + + const reqId = entity.endToEndId ?? `DEPOSIT-${entity.id}`; + + const status = this.scryptService.getDepositStatus(reqId); + if (status) { + if (status.status === ScryptTransactionStatus.REJECTED || status.status === ScryptTransactionStatus.FAILED) { + // Deliberately not terminalized: a rejected/failed deposit request needs manual intervention, + // so the entity stays in the sweep and keeps alerting (throttled) until resolved. + const lastAlert = this.scryptDepositAlerts.get(entity.id); + if (lastAlert && Date.now() - lastAlert.getTime() < SCRYPT_DEPOSIT_RETRY_INTERVAL_MS) return; + + this.scryptDepositAlerts.set(entity.id, new Date()); + this.logger.error( + `Scrypt deposit request for fiat output ${entity.id} was ${status.status}: ${status.rejectText ?? status.rejectReason ?? 'unknown reason'}`, + ); + return; + } + + if (status.status === ScryptTransactionStatus.COMPLETED) { + await this.fiatOutputRepo.update(entity.id, { scryptDepositNotifiedDate: new Date() }); + this.scryptDepositSendAttempts.delete(entity.id); + this.scryptDepositAlerts.delete(entity.id); + return; + } + + // Intermediate or unknown status (e.g. PendingApproval): the broker knows the request, + // so neither mark as notified nor re-send — wait for a terminal status. + return; + } + + const lastAttempt = this.scryptDepositSendAttempts.get(entity.id); + if (lastAttempt && Date.now() - lastAttempt.getTime() < SCRYPT_DEPOSIT_RETRY_INTERVAL_MS) return; + + await this.scryptService.sendDepositRequest({ + currency: entity.currency, + amount: entity.amount, + reqId, + timeStamp: new Date(), + }); + this.scryptDepositSendAttempts.set(entity.id, new Date()); + } } diff --git a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts index dc29133fe8..e8974f4808 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts @@ -132,6 +132,9 @@ export class FiatOutput extends IEntity { @Column({ type: 'timestamp', nullable: true }) isConfirmedDate?: Date; + @Column({ type: 'timestamp', nullable: true }) + scryptDepositNotifiedDate?: Date; + @Column({ type: 'timestamp', nullable: true }) isApprovedDate?: Date; From 66da043f00703753a53d238df0fe5445b3696227 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:04:15 +0200 Subject: [PATCH 2/2] Add RealUnit as an OpenCryptoPay payment-link wallet (#4339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add RealUnit as an OpenCryptoPay payment-link wallet Register RealUnit in the wallet_app catalog so it is offered on payment-link pages. It behaves like the AQUA wallet: Lightning-category, bare custom-scheme deepLink (realunit-wallet:), no asset restriction (assets NULL) — the ZCHF settlement happens inside the app and is invisible to the backend matching. Idempotent SELECT-then-skip guard against the UNIQUE(name) constraint, matching the existing migration convention. * Correct RealUnit classification to ZCHF on Ethereum RealUnit does not support Lightning — it consumes the OpenCryptoPay LNURL only as the payment-request identifier and settles on-chain in ZCHF on Ethereum. Set blockchains='Ethereum' and assets='251' (Ethereum/ZCHF) so it qualifies for the Ethereum/ZCHF transfer option of an OCP payment, instead of the incorrect Lightning classification. * Resolve the ZCHF asset id by uniqueName in the RealUnit migration Asset ids are environment-specific and wallet_app.assets has no FK, so a hardcoded id can silently point at the wrong (or no) asset in another environment — marking RealUnit inactive with no error. Resolve the Ethereum/ZCHF asset by its stable uniqueName at migration time and fail loud if it is missing, matching the sibling migrations that touch the same asset. --- .../1784807670011-AddRealUnitWalletApp.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 migration/1784807670011-AddRealUnitWalletApp.js diff --git a/migration/1784807670011-AddRealUnitWalletApp.js b/migration/1784807670011-AddRealUnitWalletApp.js new file mode 100644 index 0000000000..37b31512ed --- /dev/null +++ b/migration/1784807670011-AddRealUnitWalletApp.js @@ -0,0 +1,41 @@ +// Add RealUnit as a selectable wallet_app for DFX OpenCryptoPay payment-link pages. +// +// RealUnit consumes the OpenCryptoPay LNURL only as the payment-request identifier and +// settles exclusively on-chain in ZCHF on Ethereum. blockchains='Ethereum' + assets resolved +// at migration-run-time by uniqueName 'Ethereum/ZCHF' yield supportedMethods=['Ethereum'] / +// supportedAssets=[Ethereum/ZCHF], so it qualifies for the Ethereum/ZCHF transfer option of an +// OCP payment (frontend matches on supportedAsset.name === 'ZCHF'). deepLink stays the bare +// custom scheme 'realunit-wallet:'. + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +module.exports = class AddRealUnitWalletApp1784807670011 { + name = 'AddRealUnitWalletApp1784807670011'; + + async up(queryRunner) { + // Idempotent guard against UNIQUE(name): skip if RealUnit already exists. + const existing = (await queryRunner.query(`SELECT "id" FROM "wallet_app" WHERE "name" = 'RealUnit'`)).at(0); + if (existing) return; + + // Asset ids are env-specific SERIAL values; resolve the stable uniqueName at run-time. + const ethZchfAsset = (await queryRunner.query(`SELECT "id" FROM "asset" WHERE "uniqueName" = 'Ethereum/ZCHF'`)).at( + 0, + ); + if (!ethZchfAsset) { + throw new Error( + "AddRealUnitWalletApp: asset with uniqueName 'Ethereum/ZCHF' not found - cannot wire wallet_app.assets", + ); + } + + await queryRunner.query( + `INSERT INTO "wallet_app" ("name", "websiteUrl", "iconUrl", "deepLink", "hasActionDeepLink", "appStoreUrl", "playStoreUrl", "recommended", "blockchains", "assets", "semiCompatible", "active") VALUES ('RealUnit', 'https://realunit.app', 'https://dfx.swiss/images/app/realunit.webp', 'realunit-wallet:', NULL, 'https://apps.apple.com/ch/app/realunit/id6759720010', 'https://play.google.com/store/apps/details?id=swiss.realunit.app', false, 'Ethereum', '${ethZchfAsset.id}', NULL, true)`, + ); + } + + async down(queryRunner) { + await queryRunner.query(`DELETE FROM "wallet_app" WHERE "name" = 'RealUnit'`); + } +};