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/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'`); + } +}; 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;