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/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/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/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/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/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/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/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/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/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/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/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-booking.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-booking.service.spec.ts index 4400d76d62..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 = []; @@ -232,6 +250,70 @@ 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 + }); + + // #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 () => { 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..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 @@ -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,92 @@ 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'); + + 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 + 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('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 () => { jest .spyOn(logService, 'getFinancialLogs') 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/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/__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/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 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 this.prepareLeg(leg)); await this.appendRoundingLeg(legs); - this.checkNativeBalance(legs); + this.checkNativeBalance(legs, input); const amountChfSum = legs.reduce((sum, leg) => sum + leg.amountChfCents, 0); @@ -384,24 +384,38 @@ 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[]): void { + private checkNativeBalance(legs: LedgerLeg[], input: LedgerTxInput): void { const onlyAssetTransit = legs.every( (leg) => leg.account.type === AccountType.ASSET || leg.account.type === AccountType.TRANSIT, ); 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) { - this.logger.error( - `Ledger same-asset transfer native imbalance for currency ${currency}: ${nativeSum} (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)`, + ); } } 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..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`) @@ -55,6 +61,12 @@ 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). + // 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 * bounded recent-log read. Used ONLY as the documented fallback when the per-batch cache has no mark AT a historical @@ -90,6 +102,41 @@ 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()}`; + 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 entry.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 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/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/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') 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/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/__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/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/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/generic/user/models/auth/__tests__/auth.service.spec.ts b/src/subdomains/generic/user/models/auth/__tests__/auth.service.spec.ts index 933cc97342..2c400f6feb 100644 --- a/src/subdomains/generic/user/models/auth/__tests__/auth.service.spec.ts +++ b/src/subdomains/generic/user/models/auth/__tests__/auth.service.spec.ts @@ -349,5 +349,47 @@ describe('AuthService', () => { 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 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/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); } 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); 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..31d92de51c 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 { 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'; @@ -1210,4 +1212,192 @@ 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); + }); + }); + + 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 02541d7558..4a2f95c7eb 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -380,9 +380,12 @@ 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 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].includes(a.blockchain) && a.dexName === 'EUR', + (a) => [Blockchain.OLKYPAY, Blockchain.YAPEAL, Blockchain.FRICK].includes(a.blockchain) && a.dexName === 'EUR', ); // pending balances @@ -499,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', @@ -537,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 @@ -607,7 +611,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 // 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 926aad77c2..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,7 +1,18 @@ 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(...)', () => { + 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 }); @@ -13,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 588576550c..09ac9fd4d4 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; @@ -266,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 = {}; @@ -367,3 +391,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-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/__tests__/payin.service.spec.ts b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts new file mode 100644 index 0000000000..477368b416 --- /dev/null +++ b/src/subdomains/supporting/payin/services/__tests__/payin.service.spec.ts @@ -0,0 +1,315 @@ +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'; +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 { 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'; +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 }, + }); + }); + + 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-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/services/payin.service.ts b/src/subdomains/supporting/payin/services/payin.service.ts index be75df3051..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'; @@ -13,12 +13,16 @@ 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 { RetryPayInSendDto } from '../dto/retry-payin-send.dto'; import { CryptoInput, + CryptoInputInFlightSendStatus, PayInAction, PayInConfirmationType, PayInPurpose, @@ -45,6 +49,7 @@ export class PayInService { private readonly paymentLinkPaymentService: PaymentLinkPaymentService, private readonly payInBitcoinService: PayInBitcoinService, private readonly payInFiroService: PayInFiroService, + private readonly notificationService: NotificationService, ) {} // --- PUBLIC API --- // @@ -183,8 +188,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 +232,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'); @@ -241,16 +256,48 @@ 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 }) 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 +494,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/__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/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; } } 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; 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; } = { 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/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/__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..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; @@ -116,12 +125,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/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 3231b0cca9..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,9 +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, MoreThan } from 'typeorm'; +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'; @@ -16,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; @@ -137,6 +250,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 +289,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 +382,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..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'; @@ -7,8 +7,9 @@ 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 { 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 { @@ -235,18 +272,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; + } + + escalatedOrders.push(order); + } catch (e) { + this.logger.warn(`Failed to escalate payout order ${order.id}; retrying next cycle:`, e); + continue; + } + } - const logMessage = this.logs.logFailedOrders(orders); - const mailRequest = this.createMailRequest(logMessage, orders); + if (escalatedOrders.length === 0) return; - await this.notificationService.sendMail(mailRequest); + const logMessage = this.logs.logFailedOrders(escalatedOrders); + const mailRequest = this.createMailRequest(logMessage, escalatedOrders); - for (const order of orders) { - order.pendingInvestigation(); - await this.payoutOrderRepo.save(order); + // 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..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 @@ -29,14 +29,19 @@ describe('PayoutBitcoinBasedStrategy', () => { let payoutOrderRepo: PayoutOrderRepository; let bitcoinService: PayoutBitcoinBasedService; + let repoUpdateSpy: jest.SpyInstance; let repoSaveSpy: jest.SpyInstance; let sendErrorMailSpy: jest.SpyInstance; beforeEach(() => { + new ConfigService(); + Config.payout.maxPreBroadcastRetries = 3; + notificationService = mock(); 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 +49,7 @@ describe('PayoutBitcoinBasedStrategy', () => { }); afterEach(() => { + repoUpdateSpy.mockClear(); repoSaveSpy.mockClear(); sendErrorMailSpy.mockClear(); }); @@ -189,28 +195,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 +262,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 +405,114 @@ 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); + }); + + 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". @@ -366,7 +602,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..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 @@ -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(); + 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'); 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..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 @@ -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,37 +117,159 @@ 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, + 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 }); - 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('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, + releasedPayoutTxIds: 'TX_OOG', + }, + ); + 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 + 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(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 pre-broadcast designate save + expect(order.releasedPayoutTxIds).toBe('TX_RELEASED_FIRST;TX_OLD'); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // pending only; rollback and designation use UPDATE + }); + + 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('(e) pending: no status change, no completion, no designation, no rollback, no dispatch', async () => { + 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' }; @@ -167,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(...)', () => { @@ -200,6 +355,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 +377,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,10 +390,10 @@ 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 () => { + it('manual speedup re-entry + plain error: never rolls back, preserving nonce reuse', async () => { const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: 'OLD_TX', @@ -249,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'); @@ -270,7 +426,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 +443,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..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 @@ -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,31 @@ 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); + // 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; } - for (const order of orders) { + for (const order of designated) { try { order.resetPayoutRetry(); const paidOrder = order.pendingPayout(payoutTxId); @@ -196,11 +217,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..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 @@ -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,12 +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`, ); - order.rollbackPayout(); - await this.payoutOrderRepo.save(order); } 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/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..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 @@ -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,70 @@ 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 manual speedup) 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 { + // 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, + 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) { + this.designationLogger.warn(`Skipping payout retry for order ${order.id}: rollback claim failed`, e); + return false; } } @@ -64,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) { 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);