diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ebf787738..1aff32f14f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,7 @@ Missing any of these = changes requested. - **Clean API** — use DTOs, never expose entities, document with ApiProperty - **No over-engineering** — don't build it if the existing solution works - **Use existing packages** — use `@dfx.swiss/*` packages instead of duplicating logic +- **Auditable DB mutations** — no destructive overwrites; previous values must stay recoverable (see [Database / TypeORM → Auditable mutations](#auditable-mutations--no-destructive-overwrites-critical)) --- @@ -563,6 +564,29 @@ async process(data: OrderData): Promise { ... } ## Database / TypeORM +### Auditable mutations — no destructive overwrites (CRITICAL) + +Overwriting a stored value is only allowed when the **previous** value remains +**recoverable elsewhere in the database**. Every mutation must stay reconstructible: +it must always be clear **when** a field changed, **from which** previous value, and +**to which** new value. + +| Rule | Requirement | +|------|-------------| +| **No silent data loss** | If an overwrite can destroy information that is not still queryable on another durable row/event, it is a **bug**. | +| **Event before snapshot** | Write the immutable event first (e.g. registration `signedPayload`, superseded history rows, append-only log). Only then update a denormalised “current” column. | +| **Before → after audit** | For mutable snapshot columns, record `previous` and `next` (and identity/context) **before** the column update. If the audit write fails, do **not** change the column (fail closed). | +| **No destructive clear** | Do not set a non-null field to `null`/empty when the new business event does not itself retain that prior payload (e.g. clearing `user_data.tin` on a Swiss-only registration that omits `countryAndTINs`). | + +**Allowed pattern:** dual-store current snapshot + immutable history. + +- **History / event of record** — append-only or supersede-with-history rows (old row stays, `active = false`, payload intact). +- **Snapshot** — query-friendly “current” column, updated only after history is durable and only with an audit trail for the transition. + +**Not allowed:** in-place `UPDATE` that replaces or nulls a field when the old value exists nowhere else; “stale cleanup” that drops data without a recoverable prior record. + +When reviewing PRs that touch persistence, ask: *If this write runs, can an investigator still reconstruct the previous value and the change time from the DB alone?* If not, reject the change. + ### Entity Patterns ```typescript diff --git a/docs/specs/scorechain-integration.md b/docs/specs/scorechain-integration.md index dec0c43d7e..8647b83ea1 100644 --- a/docs/specs/scorechain-integration.md +++ b/docs/specs/scorechain-integration.md @@ -367,6 +367,19 @@ and consistent — no untyped failures, no silent passes: it must never be returned to customers or partner webhooks; only Support/Compliance read it (e.g. the `RoleGuard(SUPPORT)`-gated support-issue data endpoint). Do not add a Scorechain-named `AmlReason` and do not surface `comment` in any customer-facing DTO. + + **HighRisk vs. Unavailable (DFXswiss/api#4205):** a real Scorechain hit and a + provider/infrastructure failure (5xx, timeout, quota reached, misconfiguration, + unexpected throw) are different events and must not collapse into the same classification — + a Prod incident showed a Buy-Crypto tx routed to manual review with comment + `ScorechainHighRisk` although Scorechain had returned HTTP 500 and no + `scorechain_screening` row or compliance PDF ever existed for it. The + call-site now returns a `ScorechainOutcome` (`PASS` / `HIGH_RISK` / `UNAVAILABLE`, + `scorechain-outcome.enum.ts`) instead of a boolean; `HIGH_RISK` still maps to + `AmlError.SCORECHAIN_HIGH_RISK`, and the new `AmlError.SCORECHAIN_UNAVAILABLE` (mapped + identically: `{ type: CRUCIAL, amlCheck: PENDING, amlReason: MANUAL_CHECK }`) covers the failure + case. Customer-facing fail-closed behaviour is unchanged (both still route to manual review); + only the internal classification — visible in `comment` and stats — is now accurate. 2. **TMS depth:** the **synchronous `scoringAnalysis` gate is the only implemented flow**. The async TMS workflow (`register*` → scenarios/alerts) is a license-gated feature (`NotIncludedInLicense`) that DFX does **not** license. Its placeholder client methods, the @@ -381,10 +394,14 @@ and consistent — no untyped failures, no silent passes: `SCORECHAIN_MONTHLY_CHECK_LIMIT` (or a provider/transport error) throws `ServiceUnavailableException` inside the screening service. The AML call-site (`screenScorechain` in `buy-crypto-preparation` / `buy-fiat-preparation`) catches it and - returns high risk, so the tx is routed to manual review (`AmlError.SCORECHAIN_HIGH_RISK` → - `CheckStatus.PENDING`) instead of throwing out of the AML computation. Letting the throw escape - the compute path would silently stall settlement of every otherwise-passing tx on every cron - run, so the error is deliberately caught at the seam. + returns `ScorechainOutcome.UNAVAILABLE`, so the tx is routed to manual review + (`AmlError.SCORECHAIN_UNAVAILABLE` → `CheckStatus.PENDING`) instead of throwing out of the AML + computation — deliberately **not** `SCORECHAIN_HIGH_RISK`, since no risk verdict could be + established: for a transport/quota error neither a `scorechain_screening` row nor a compliance + PDF exists, but for a missing risk-threshold configuration both may already have been + persisted before the throw. Letting the throw escape the compute path would + silently stall settlement of every otherwise-passing tx on every cron run, so the error is + deliberately caught at the seam. 5. **ikna coexistence:** run both fully in parallel (no routing rule); Scorechain is additive. 6. **Live pipeline injection (call-sites):** the async screening is wired into the synchronous AML pipeline (`screenScorechain` is threaded through `amlCheckAndFillUp` for every buy/sell that diff --git a/migration/1784000000000-WidenUserDataTin.js b/migration/1784000000000-WidenUserDataTin.js new file mode 100644 index 0000000000..193973707a --- /dev/null +++ b/migration/1784000000000-WidenUserDataTin.js @@ -0,0 +1,41 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Widens `user_data.tin` from varchar(256) to varchar(1024). The RealUnit multi-tax-residence + * declaration is stored as a JSON array of {country, tin} in this column; 256 characters is no + * longer enough from ~7 entries on (seven 11-digit TINs already serialize past 256). Widening a + * varchar length in Postgres is a catalog-only change with no table rewrite. + * + * down() fails loud if longer values already exist — that is intentional (fail-closed: do not + * silently truncate tax-residence snapshots). + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class WidenUserDataTin1784000000000 { + name = 'WidenUserDataTin1784000000000' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // Fail fast instead of head-of-queue-blocking every "user_data" write if the ALTER is + // contended at deploy time: the timeout aborts with a clear lock_timeout error instead of + // hanging app boot. + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "user_data" ALTER COLUMN "tin" TYPE character varying(1024)`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + // Fails loud if any row already stores more than 256 characters — intentional; do not + // silently truncate a multi-tax-residence snapshot. + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "user_data" ALTER COLUMN "tin" TYPE character varying(256)`); + } +} diff --git a/migration/1784029705806-EnablePhilippinesOnboarding.js b/migration/1784029705806-EnablePhilippinesOnboarding.js new file mode 100644 index 0000000000..37ae62df29 --- /dev/null +++ b/migration/1784029705806-EnablePhilippinesOnboarding.js @@ -0,0 +1,27 @@ +// Enable individual DFX onboarding for the Philippines. +// +// The Philippines is no longer subject to FATF increased monitoring and its country row is already +// FATF-enabled. The remaining dfxEnable=false flag keeps it out of the DFX KYC country list and also +// makes bankAllowed false in the public country DTO. Keep organization onboarding and all unrelated +// country controls unchanged. This regulatory allow-list change is intentionally one-way: reverting an +// application deployment must not silently block the country again. A future restriction requires its +// own explicit migration and compliance review. + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +module.exports = class EnablePhilippinesOnboarding1784029705806 { + name = 'EnablePhilippinesOnboarding1784029705806'; + + async up(queryRunner) { + await queryRunner.query( + `UPDATE "country" SET "dfxEnable" = true, "updated" = NOW() WHERE "symbol" = 'PH' AND "dfxEnable" = false`, + ); + } + + async down(_queryRunner) { + // Intentionally empty: country restrictions are compliance data and must be changed explicitly. + } +}; diff --git a/src/config/config.ts b/src/config/config.ts index 56ef49ed3c..54ea8ebc99 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -782,6 +782,13 @@ export class Configuration { ], }; + payout = { + // Cap on auto-retries for a payout order that fails provably before the on-chain send call + // (gas estimation, nonce fetch, gasPrice RPC). Beyond this, a permanently failing pre-broadcast + // step (e.g. gas-estimation revert) escalates to PAYOUT_UNCERTAIN instead of retrying forever. + maxPreBroadcastRetries: +(process.env.PAYOUT_MAX_PRE_BROADCAST_RETRIES ?? 3), + }; + blockchain = { default: { user: process.env.NODE_USER, diff --git a/src/integration/blockchain/arkade/__tests__/arkade-client.spec.ts b/src/integration/blockchain/arkade/__tests__/arkade-client.spec.ts index cd2ddc419f..227d93bcfe 100644 --- a/src/integration/blockchain/arkade/__tests__/arkade-client.spec.ts +++ b/src/integration/blockchain/arkade/__tests__/arkade-client.spec.ts @@ -1,4 +1,5 @@ import { Wallet } from '@arkade-os/sdk'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { ArkadeClient } from '../arkade-client'; // Mock the SDK to provide InMemory repositories @@ -105,6 +106,100 @@ describe('ArkadeClient', () => { }); }); + // --- SEND TRANSACTION - BROADCAST BOUNDARY --- // + // wallet.sendBitcoin signs AND broadcasts atomically inside the SDK, so it must never be retried by + // this.call()'s reconnect-and-retry (which would resend an already-broadcast tx). Any failure at or + // after that call is wrapped into a TxBroadcastError; wallet resolution failures never reach + // sendBitcoin and stay plain errors. + + describe('sendTransaction - broadcast boundary', () => { + it('wraps a sendBitcoin failure into a TxBroadcastError, keeping message and cause', async () => { + const cause = new Error('unexpected rejection'); + mockWallet.sendBitcoin.mockRejectedValue(cause); + + let error: unknown; + try { + await client.sendTransaction('ark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('unexpected rejection'); + expect((error as TxBroadcastError).cause).toBe(cause); + }); + + it('wraps a non-Error rejection using String(e)', async () => { + mockWallet.sendBitcoin.mockRejectedValue('plain string rejection'); + + let error: unknown; + try { + await client.sendTransaction('ark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('plain string rejection'); + }); + + it('never resends when sendBitcoin fails with a connection-classified error (fail-closed, no double-send), but resets the cache so the next call reconnects', async () => { + mockWallet.sendBitcoin.mockRejectedValue(new Error('connection lost')); + + await expect(client.sendTransaction('ark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.sendBitcoin).toHaveBeenCalledTimes(1); // never resent within this call + + const freshWallet = { ...mockWallet, sendBitcoin: jest.fn().mockResolvedValue('tx-fresh') }; + jest.spyOn(Wallet, 'create').mockResolvedValue(freshWallet as any); + + const result = await client.sendTransaction('ark1destination', 0.5); + + expect(result).toEqual({ txid: 'tx-fresh', fee: 0 }); // next call picked up the fresh wallet + expect(freshWallet.sendBitcoin).toHaveBeenCalledTimes(1); + }); + + it('does not reset the cache when sendBitcoin fails with an unrelated error', async () => { + mockWallet.sendBitcoin.mockRejectedValue(new Error('insufficient funds')); + + await expect(client.sendTransaction('ark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + + mockWallet.sendBitcoin.mockResolvedValue('tx-retry'); + await client.sendTransaction('ark1destination', 0.5); + + // still the same wallet instance from Wallet.create - no reinitialization happened + expect(Wallet.create).toHaveBeenCalledTimes(1); + }); + + it('propagates a wallet resolution failure as a plain (non-TxBroadcastError) error and resets the cache, never reaching sendBitcoin', async () => { + jest.spyOn(Wallet, 'create').mockRejectedValue(new Error('server down')); + const freshClient = new ArkadeClient(); + + let error: unknown; + try { + await freshClient.sendTransaction('ark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('server down'); + expect(mockWallet.sendBitcoin).not.toHaveBeenCalled(); + + // reset proof: the next attempt re-initializes instead of reusing the permanently-rejected AsyncField + jest.spyOn(Wallet, 'create').mockResolvedValue(mockWallet as any); + const result = await freshClient.sendTransaction('ark1destination', 0.5); + expect(result).toEqual({ txid: 'tx-abc123', fee: 0 }); + }); + + it('wraps an empty txid from sendBitcoin into a TxBroadcastError (fail-closed silent failure)', async () => { + mockWallet.sendBitcoin.mockResolvedValue(''); + + await expect(client.sendTransaction('ark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.sendBitcoin).toHaveBeenCalledTimes(1); + }); + }); + // --- GET TRANSACTION --- // describe('getTransaction', () => { diff --git a/src/integration/blockchain/arkade/arkade-client.ts b/src/integration/blockchain/arkade/arkade-client.ts index 5d17a40ad5..abe58f8660 100644 --- a/src/integration/blockchain/arkade/arkade-client.ts +++ b/src/integration/blockchain/arkade/arkade-client.ts @@ -5,6 +5,7 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { AsyncField } from 'src/shared/utils/async-field'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient } from '../shared/util/blockchain-client'; export interface ArkadeTransaction { @@ -52,16 +53,45 @@ export class ArkadeClient extends BlockchainClient { // --- TRANSACTION METHODS --- // async sendTransaction(to: string, amount: number): Promise<{ txid: string; fee: number }> { - return this.call(async (wallet) => { - const amountSats = Math.round(amount * 1e8); + // Pre-broadcast: resolving the wallet handle never calls sendBitcoin, so a failure here is + // provably pre-broadcast. Reset the cache so the next retry gets a fresh connection attempt + // instead of repeating a possibly permanently-rejected cached promise. + let wallet: Wallet; + try { + wallet = await this.wallet; + } catch (e) { + this.wallet.reset(); + this.cachedAddress.reset(); + throw e; + } + + const amountSats = Math.round(amount * 1e8); + // Broadcast boundary: wallet.sendBitcoin signs AND broadcasts atomically inside the SDK - there + // is no separate pre-broadcast step to isolate, so any failure from here on is ambiguous (the + // tx may already be on-chain). Deliberately NOT routed through this.call()'s reconnect-and-retry: + // that wrapper re-invokes the whole operation on a connection-classified error, which for a + // broadcast would risk sending twice. Reset the cache on such an error so only the *next* call + // reconnects, without resending this one. + try { const txid = await wallet.sendBitcoin({ address: to, amount: amountSats, }); + // An empty txid from the SDK is an ambiguous silent failure - fail-closed rather than + // returning it and letting the empty id roll the order back for re-broadcast. + if (!txid) throw new TxBroadcastError('Arkade broadcast returned an empty txid'); + return { txid, fee: 0 }; - }); + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + if (e?.message?.includes('disconnected') || e?.message?.includes('connection')) { + this.wallet.reset(); + this.cachedAddress.reset(); + } + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async getTransaction(txId: string): Promise { diff --git a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts index 5c5ce1ca41..113c71cdae 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts @@ -6,6 +6,7 @@ */ import { HttpService } from 'src/shared/services/http.service'; +import { TxBroadcastError } from '../../../shared/errors/tx-broadcast.error'; import { BitcoinClient } from '../bitcoin-client'; // Mock Config and GetConfig @@ -285,6 +286,48 @@ describe('BitcoinClient', () => { expect(result).toBe('newtxid123'); }); + + // Bitcoin Core's `send` RPC builds, signs and broadcasts atomically in one node call - a + // failure here (including an HTTP-level timeout) is ambiguous, so it must surface as + // TxBroadcastError rather than a plain Error, mirroring the Cardano/Solana broadcast boundary. + it('should wrap a failure of the underlying `send` RPC call into a TxBroadcastError', async () => { + // BitcoinRpcClient.call() itself wraps any transport-level rejection into a plain Error + // prefixed with "Bitcoin RPC failed: ..." before it reaches sendMany - that + // intermediate Error is what our TxBroadcastError boundary observes and preserves as cause. + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase + mockRpcPost.mockImplementationOnce(() => Promise.reject(new Error('Invalid amount'))); + + let error: unknown; + try { + await client.sendMany(payload, 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin RPC send failed: Invalid amount'); + expect((error as TxBroadcastError).cause).toBeInstanceOf(Error); + expect(((error as TxBroadcastError).cause as Error).message).toBe('Bitcoin RPC send failed: Invalid amount'); + }); + + it('should wrap a non-Error rejection of the `send` RPC call into a TxBroadcastError via String(e)', async () => { + const payload = [{ addressTo: 'bc1qaddr1', amount: 0.1 }]; + + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase + mockRpcPost.mockImplementationOnce(() => Promise.reject('node unreachable')); + + let error: unknown; + try { + await client.sendMany(payload, 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin RPC send failed: node unreachable'); + }); }); // --- testMempoolAccept() Tests --- // diff --git a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts index 545d404125..65def00ea1 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts @@ -3,6 +3,7 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpService } from 'src/shared/services/http.service'; import { BlockchainTokenBalance } from '../../shared/dto/blockchain-token-balance.dto'; import { BlockchainSignedTransactionResponse } from '../../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../../shared/errors/tx-broadcast.error'; import { CoinOnly } from '../../shared/util/blockchain-client'; import { NodeClient, NodeClientConfig } from './node-client'; @@ -69,9 +70,16 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly ...(subtractFeeFromOutputs && { subtract_fee_from_outputs: subtractFeeFromOutputs }), }; - const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); - - return result?.txid ?? ''; + // Broadcast boundary: Bitcoin Core's `send` RPC builds, signs and broadcasts in one atomic + // node-side call - there is no separate pre-broadcast step to exclude. Any failure surfacing + // from this call (including an HTTP-level timeout) is ambiguous: the node may already have + // relayed the tx before the response was lost, so it is treated as at-or-after-send. + try { + const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); + return result?.txid ?? ''; + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async sendManyFromAddress( diff --git a/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts b/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts new file mode 100644 index 0000000000..a59c7ec860 --- /dev/null +++ b/src/integration/blockchain/cardano/__tests__/cardano-client.spec.ts @@ -0,0 +1,91 @@ +/** + * Focused unit test for the CardanoClient broadcast boundary: the try/catch around the on-chain + * submit call in `sendNativeCoin` (blockFrostApi.txSubmit), which translates any failure at/after + * the broadcast call into a TxBroadcastError. Everything before that call (building/signing the + * transaction via createSignedTransactionBlockFrost) is provably pre-broadcast and must keep + * propagating as a plain error. + * + * CardanoClient's constructor wires up a real CardanoWallet from a mnemonic and other collaborators + * that need live config. To isolate JUST the changed method, we call the private prototype method + * directly via Function.prototype.call against a minimal stub `this`, following the same pattern as + * evm-client.spec.ts. + */ + +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { CardanoClient } from '../cardano-client'; + +const proto = CardanoClient.prototype as any; + +function createClientStub(txSubmit: jest.Mock): any { + const client = Object.create(CardanoClient.prototype); + + client.createSignedTransactionBlockFrost = jest.fn().mockResolvedValue('SIGNED_TX_HEX'); + client.getBlockFrostAPI = jest.fn().mockReturnValue({ txSubmit }); + + return client; +} + +describe('CardanoClient - broadcast boundary', () => { + describe('sendNativeCoin(...)', () => { + it('returns the tx hash on a successful submit', async () => { + const txSubmit = jest.fn().mockResolvedValue('TX_HASH_01'); + const client = createClientStub(txSubmit); + + const hash = await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + + expect(hash).toBe('TX_HASH_01'); + expect(txSubmit).toHaveBeenCalledWith('SIGNED_TX_HEX'); + }); + + it('wraps an Error thrown by blockFrostApi.txSubmit into a TxBroadcastError, preserving message and cause', async () => { + const submitError = new Error('UTXO already spent'); + const txSubmit = jest.fn().mockRejectedValue(submitError); + const client = createClientStub(txSubmit); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('UTXO already spent'); + expect((error as TxBroadcastError).cause).toBe(submitError); + }); + + it('wraps a non-Error rejection from blockFrostApi.txSubmit into a TxBroadcastError via String(e)', async () => { + const txSubmit = jest.fn().mockRejectedValue('BlockFrost timeout'); + const client = createClientStub(txSubmit); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('BlockFrost timeout'); + expect((error as TxBroadcastError).cause).toBe('BlockFrost timeout'); + }); + + it('does not wrap a failure from building/signing the tx (pre-broadcast, plain error propagates unchanged)', async () => { + const preBroadcastError = new Error('insufficient UTXOs'); + const client: any = Object.create(CardanoClient.prototype); + client.createSignedTransactionBlockFrost = jest.fn().mockRejectedValue(preBroadcastError); + client.getBlockFrostAPI = jest.fn(); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, { address: 'FROM_ADDR' }, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(preBroadcastError); // same object - not wrapped + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(client.getBlockFrostAPI).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/integration/blockchain/cardano/cardano-client.ts b/src/integration/blockchain/cardano/cardano-client.ts index 57f72821b5..2949bb884d 100644 --- a/src/integration/blockchain/cardano/cardano-client.ts +++ b/src/integration/blockchain/cardano/cardano-client.ts @@ -19,6 +19,7 @@ import { } from '@emurgo/cardano-serialization-lib-nodejs'; import blake from 'blakejs'; import { Config } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpRequestConfig, HttpService } from 'src/shared/services/http.service'; import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; @@ -156,7 +157,12 @@ export class CardanoClient extends BlockchainClient { const signedTransactionHex = await this.createSignedTransactionBlockFrost(wallet, toAddress, amount); const blockFrostApi = this.getBlockFrostAPI(); - return blockFrostApi.txSubmit(signedTransactionHex); + + try { + return await blockFrostApi.txSubmit(signedTransactionHex); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } private async createSignedTransactionBlockFrost( diff --git a/src/integration/blockchain/firo/__tests__/firo-client.spec.ts b/src/integration/blockchain/firo/__tests__/firo-client.spec.ts new file mode 100644 index 0000000000..a40eef0436 --- /dev/null +++ b/src/integration/blockchain/firo/__tests__/firo-client.spec.ts @@ -0,0 +1,248 @@ +/** + * Unit tests for FiroClient's broadcast boundary. + * + * Firo has no atomic Bitcoin Core `send` RPC, so the client builds+signs locally + * (createrawtransaction/signrawtransaction - pre-broadcast, plain Error) and only broadcasts via a + * final sendrawtransaction call (mintspark is the exception: it builds+signs+broadcasts atomically + * in one node-side call, like Bitcoin Core's `send`). Only a failure at/after that final call is + * ambiguous and must surface as TxBroadcastError. + */ + +import { HttpService } from 'src/shared/services/http.service'; +import { TxBroadcastError } from '../../shared/errors/tx-broadcast.error'; +import { FiroClient } from '../firo-client'; + +jest.mock('src/config/config', () => { + const mockConfig = { + blockchain: { + firo: { + user: 'testuser', + password: 'testpass', + walletPassword: 'walletpass123', + walletAddress: 'tWalletAddress', + allowUnconfirmedUtxos: true, + transparentTxSize: 225, + sparkMintTxSize: 480, + inputSize: 225, + outputSize: 34, + txOverhead: 10, + }, + }, + payment: { + firoAddress: 'tPaymentAddress', + }, + }; + return { + Config: mockConfig, + GetConfig: () => mockConfig, + }; +}); + +describe('FiroClient - broadcast boundary', () => { + let client: FiroClient; + let mockRpcPost: jest.Mock; + let lastRpcCalls: Array<{ method: string; params: any[] }>; + + beforeEach(() => { + lastRpcCalls = []; + + mockRpcPost = jest.fn().mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + lastRpcCalls.push({ method: parsed.method, params: parsed.params }); + + switch (parsed.method) { + case 'walletpassphrase': + return Promise.resolve({ result: null, error: null, id: 'test' }); + case 'listunspent': + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + case 'createrawtransaction': + return Promise.resolve({ result: 'rawtxhex', error: null, id: 'test' }); + case 'signrawtransaction': + return Promise.resolve({ result: { hex: 'signedtxhex', complete: true }, error: null, id: 'test' }); + case 'sendrawtransaction': + return Promise.resolve({ result: 'broadcasttxid123', error: null, id: 'test' }); + case 'mintspark': + return Promise.resolve({ result: ['minttxid123'], error: null, id: 'test' }); + default: + return Promise.resolve({ result: null, error: null, id: 'test' }); + } + }); + + const mockHttpService = { + post: mockRpcPost, + get: jest.fn(), + } as unknown as jest.Mocked; + + client = new FiroClient(mockHttpService, 'http://localhost:8000'); + }); + + describe('sendMany(...) -> buildSignAndBroadcast(...)', () => { + it('returns the txid on a successful sendrawtransaction call', async () => { + const result = await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); + + expect(result).toBe('broadcasttxid123'); + }); + + it('wraps a failure of the final sendrawtransaction call into a TxBroadcastError', async () => { + // BitcoinRpcClient.call() (shared by Bitcoin/Firo) wraps any transport-level rejection into + // a plain Error prefixed with "Bitcoin RPC failed: ..." before it reaches + // buildSignAndBroadcast - that intermediate Error is observed and preserved as cause. + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'sendrawtransaction') return Promise.reject(new Error('tx-fee-not-met')); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + if (parsed.method === 'listunspent') + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + if (parsed.method === 'createrawtransaction') + return Promise.resolve({ result: 'rawtxhex', error: null, id: 'test' }); + if (parsed.method === 'signrawtransaction') + return Promise.resolve({ result: { hex: 'signedtxhex', complete: true }, error: null, id: 'test' }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin RPC sendrawtransaction failed: tx-fee-not-met'); + }); + + it('does not wrap a pre-broadcast signing failure (plain Error propagates unchanged)', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'signrawtransaction') + return Promise.resolve({ result: { hex: '', complete: false }, error: null, id: 'test' }); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + if (parsed.method === 'listunspent') + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + if (parsed.method === 'createrawtransaction') + return Promise.resolve({ result: 'rawtxhex', error: null, id: 'test' }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('Failed to sign Firo transaction'); + }); + + it('does not wrap a pre-broadcast UTXO-fetch failure (plain Error propagates unchanged)', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'listunspent') return Promise.reject(new Error('connection timeout')); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.sendMany([{ addressTo: 'tDestAddr', amount: 1 }], 10); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Bitcoin RPC listunspent failed: connection timeout'); + }); + }); + + describe('mintSpark(...)', () => { + it('returns the first txid on a successful mintspark call', async () => { + const result = await client.mintSpark([{ address: 'sparkAddr1', amount: 1 }]); + + expect(result).toBe('minttxid123'); + }); + + it('wraps a failure of the mintspark call into a TxBroadcastError', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'mintspark') return Promise.reject(new Error('insufficient funds')); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + if (parsed.method === 'listunspent') + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.mintSpark([{ address: 'sparkAddr1', amount: 1 }]); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Bitcoin RPC mintspark failed: insufficient funds'); + }); + + it('does not wrap an RPC-successful-but-empty mintspark result (deterministic negative ack, plain Error)', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'mintspark') return Promise.resolve({ result: [], error: null, id: 'test' }); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + if (parsed.method === 'listunspent') + return Promise.resolve({ + result: [{ txid: 'utxo-1', vout: 0, address: 'tLiquidityAddr', amount: 5, confirmations: 6 }], + error: null, + id: 'test', + }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.mintSpark([{ address: 'sparkAddr1', amount: 1 }]); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('mintspark returned no transaction IDs'); + }); + + it('does not wrap a pre-broadcast no-UTXO failure (plain Error propagates unchanged)', async () => { + mockRpcPost.mockImplementation((_url, body) => { + const parsed = JSON.parse(body); + if (parsed.method === 'listunspent') return Promise.resolve({ result: [], error: null, id: 'test' }); + if (parsed.method === 'walletpassphrase') return Promise.resolve({ result: null, error: null, id: 'test' }); + return Promise.resolve({ result: null, error: null, id: 'test' }); + }); + + let error: unknown; + try { + await client.mintSpark([{ address: 'sparkAddr1', amount: 1 }]); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('No non-deposit addresses with UTXOs available'); + }); + }); +}); diff --git a/src/integration/blockchain/firo/firo-client.ts b/src/integration/blockchain/firo/firo-client.ts index f3442dbf76..dd3697d50d 100644 --- a/src/integration/blockchain/firo/firo-client.ts +++ b/src/integration/blockchain/firo/firo-client.ts @@ -3,6 +3,7 @@ import { HttpService } from 'src/shared/services/http.service'; import { BitcoinBasedClient, TestMempoolResult } from '../bitcoin/node/bitcoin-based-client'; import { UTXO } from '../bitcoin/node/dto/bitcoin-transaction.dto'; import { Block, NodeClientConfig } from '../bitcoin/node/node-client'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { FiroRawTransaction } from './rpc'; /** @@ -189,7 +190,13 @@ export class FiroClient extends BitcoinBasedClient { throw new Error('Failed to sign Firo transaction'); } - return this.callNode(() => this.rpc.call('sendrawtransaction', [signedResult.hex]), true); + // Broadcast boundary: sendrawtransaction is the actual network relay - everything above + // (createrawtransaction/signrawtransaction) is local/pre-broadcast and stays a plain Error. + try { + return await this.callNode(() => this.rpc.call('sendrawtransaction', [signedResult.hex]), true); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } // Delegates to sendMany which uses manual coin selection from the liquidity and payment addresses. @@ -240,11 +247,21 @@ export class FiroClient extends BitcoinBasedClient { }; } - const mintTxIds = await this.callNode( - () => this.rpc.call('mintspark', [sparkAddresses, false, fromAddresses]), - true, - ); + // Broadcast boundary: mintspark builds, signs and broadcasts atomically in one node-side + // call (like Bitcoin Core's `send`) - a failure here is at-or-after the broadcast. + let mintTxIds: string[]; + try { + mintTxIds = await this.callNode( + () => this.rpc.call('mintspark', [sparkAddresses, false, fromAddresses]), + true, + ); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } + // The RPC call itself succeeded (no network ambiguity) but returned no txid - the node is + // explicitly telling us nothing was minted, so this is a provable pre-broadcast-equivalent + // failure and stays a plain Error (self-heal / rollback is safe, nothing was sent). if (!mintTxIds?.length) { throw new Error('mintspark returned no transaction IDs'); } diff --git a/src/integration/blockchain/icp/__tests__/icp-client.spec.ts b/src/integration/blockchain/icp/__tests__/icp-client.spec.ts new file mode 100644 index 0000000000..66544c1f4e --- /dev/null +++ b/src/integration/blockchain/icp/__tests__/icp-client.spec.ts @@ -0,0 +1,202 @@ +/** + * Focused unit test for the InternetComputerClient broadcast boundary: the try/catch tightly + * wrapping the actual update call (ledger.icrc1Transfer / tokenLedger.transfer), which translates + * any failure at/after that ambiguous IC update call into a TxBroadcastError. Building the transfer + * request (Principal parsing, amount conversion) happens BEFORE the try and must keep propagating + * as a plain, provably pre-broadcast error - same boundary shape as cardano-client.spec.ts / + * solana-client.spec.ts. + * + * InternetComputerClient's constructor wires up a real wallet/agent from a seed and other + * collaborators that need live config. To isolate JUST the changed methods, we call the private + * prototype methods directly via Function.prototype.call against a minimal stub `this`, following + * the same pattern as evm/cardano/solana-client.spec.ts. + */ + +import { IcpLedgerCanister } from '@dfinity/ledger-icp'; +import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { InternetComputerClient } from '../icp-client'; + +const proto = InternetComputerClient.prototype as any; + +// Real, checksum-valid IC principal texts (management canister / ICP ledger canister). No network +// call is made - Principal.fromText only needs a well-formed textual principal to parse locally. +const VALID_PRINCIPAL = 'aaaaa-aa'; +const VALID_CANISTER_ID = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; + +function createClientStub(host = 'https://icp-host.example'): any { + const client = Object.create(InternetComputerClient.prototype); + client.host = host; + return client; +} + +function createWalletStub(): any { + return { getAgent: jest.fn().mockReturnValue({ agent: true }) }; +} + +describe('InternetComputerClient - broadcast boundary', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('returns the block index as a string on a successful icrc1Transfer', async () => { + const icrc1Transfer = jest.fn().mockResolvedValue(42n); + jest.spyOn(IcpLedgerCanister, 'create').mockReturnValue({ icrc1Transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + const result = await proto.sendNativeCoin.call(client, wallet, VALID_PRINCIPAL, 1); + + expect(result).toBe('42'); + expect(icrc1Transfer).toHaveBeenCalledWith({ + to: { owner: expect.anything(), subaccount: [] }, + amount: 100000000n, + }); + }); + + it('wraps an Error thrown by icrc1Transfer into a TxBroadcastError, preserving message and cause', async () => { + const sendError = new Error('IC boundary node timeout'); + const icrc1Transfer = jest.fn().mockRejectedValue(sendError); + jest.spyOn(IcpLedgerCanister, 'create').mockReturnValue({ icrc1Transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, VALID_PRINCIPAL, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('IC boundary node timeout'); + expect((error as TxBroadcastError).cause).toBe(sendError); + }); + + it('wraps a non-Error rejection from icrc1Transfer into a TxBroadcastError via String(e)', async () => { + const icrc1Transfer = jest.fn().mockRejectedValue('reject-timeout'); + jest.spyOn(IcpLedgerCanister, 'create').mockReturnValue({ icrc1Transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, VALID_PRINCIPAL, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('reject-timeout'); + expect((error as TxBroadcastError).cause).toBe('reject-timeout'); + }); + + it('does not wrap a failure from building the transfer request (pre-broadcast, plain error propagates unchanged)', async () => { + const icrc1Transfer = jest.fn(); + jest.spyOn(IcpLedgerCanister, 'create').mockReturnValue({ icrc1Transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'not-a-valid-principal', 1); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(icrc1Transfer).not.toHaveBeenCalled(); // broadcast never reached + }); + }); + + describe('sendToken(...)', () => { + const token = createCustomAsset({ chainId: VALID_CANISTER_ID, decimals: 8, uniqueName: 'ICP/ckBTC' }); + + it('returns "canisterId:blockIndex" on a successful transfer', async () => { + const transfer = jest.fn().mockResolvedValue(7n); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + const result = await proto.sendToken.call(client, wallet, VALID_PRINCIPAL, token, 1); + + expect(result).toBe(`${VALID_CANISTER_ID}:7`); + }); + + it('wraps an Error thrown by transfer into a TxBroadcastError, preserving message and cause', async () => { + const sendError = new Error('IC boundary node timeout'); + const transfer = jest.fn().mockRejectedValue(sendError); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, VALID_PRINCIPAL, token, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('IC boundary node timeout'); + expect((error as TxBroadcastError).cause).toBe(sendError); + }); + + it('wraps a non-Error rejection from transfer into a TxBroadcastError via String(e)', async () => { + const transfer = jest.fn().mockRejectedValue('reject-timeout'); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, VALID_PRINCIPAL, token, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('reject-timeout'); + }); + + it('does not wrap a missing canister ID (pre-broadcast, plain error propagates unchanged)', async () => { + const transfer = jest.fn(); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + const tokenWithoutChainId = createCustomAsset({ chainId: undefined, uniqueName: 'NO_CHAIN_ID' }); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, VALID_PRINCIPAL, tokenWithoutChainId, 1); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('No canister ID for token NO_CHAIN_ID'); + expect(transfer).not.toHaveBeenCalled(); + }); + + it('does not wrap a failure from building the transfer request (pre-broadcast, plain error propagates unchanged)', async () => { + const transfer = jest.fn(); + jest.spyOn(IcrcLedgerCanister, 'create').mockReturnValue({ transfer } as any); + const client = createClientStub(); + const wallet = createWalletStub(); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'not-a-valid-principal', token, 1); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(transfer).not.toHaveBeenCalled(); // broadcast never reached + }); + }); +}); diff --git a/src/integration/blockchain/icp/icp-client.ts b/src/integration/blockchain/icp/icp-client.ts index 42885101db..8b3c7dbfa8 100644 --- a/src/integration/blockchain/icp/icp-client.ts +++ b/src/integration/blockchain/icp/icp-client.ts @@ -3,6 +3,7 @@ import { IcpLedgerCanister } from '@dfinity/ledger-icp'; import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; import { Principal } from '@dfinity/principal'; import { Config, GetConfig } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { HttpService } from 'src/shared/services/http.service'; @@ -368,15 +369,24 @@ export class InternetComputerClient extends BlockchainClient { const agent = wallet.getAgent(this.host); const ledger = IcpLedgerCanister.create({ agent }); - const blockIndex = await ledger.icrc1Transfer({ + // Pre-broadcast: build/validate the request (Principal parsing, amount conversion) OUTSIDE the + // broadcast boundary so a malformed address fails as a plain, provably-pre-send error. + const transferArgs: Parameters[0] = { to: { owner: Principal.fromText(toAddress), subaccount: [], }, amount: InternetComputerUtil.toSmallestUnit(amount), - }); + }; - return blockIndex.toString(); + // Broadcast boundary: an ICP update call is ambiguous once sent - the IC boundary node may have + // already accepted/routed it before a network error surfaces here. + try { + const blockIndex = await ledger.icrc1Transfer(transferArgs); + return blockIndex.toString(); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } // --- Send token --- @@ -415,15 +425,22 @@ export class InternetComputerClient extends BlockchainClient { canisterId: Principal.fromText(canisterId), }); - const blockIndex = await tokenLedger.transfer({ + // Pre-broadcast: build/validate the request OUTSIDE the broadcast boundary (see sendNativeCoin). + const transferArgs: Parameters[0] = { to: { owner: Principal.fromText(toAddress), subaccount: [], }, amount: InternetComputerUtil.toSmallestUnit(amount, token.decimals), - }); + }; - return `${canisterId}:${blockIndex}`; + // Broadcast boundary: see sendNativeCoin. + try { + const blockIndex = await tokenLedger.transfer(transferArgs); + return `${canisterId}:${blockIndex}`; + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } // --- ICRC-2 Approve/TransferFrom --- diff --git a/src/integration/blockchain/monero/__tests__/monero-client.spec.ts b/src/integration/blockchain/monero/__tests__/monero-client.spec.ts new file mode 100644 index 0000000000..76c960ada5 --- /dev/null +++ b/src/integration/blockchain/monero/__tests__/monero-client.spec.ts @@ -0,0 +1,134 @@ +/** + * Unit tests for MoneroClient's broadcast boundary. + * + * The wallet RPC's 'transfer' method builds, signs and relays a Monero transaction atomically in + * one call - there is no separate pre-broadcast step to exclude. A failure of the HTTP call + * itself, an RPC-level error field, or a missing result are all ambiguous (the wallet may have + * already relayed before the response was lost/rejected) and must surface as TxBroadcastError, + * mirroring the Solana sendTransaction boundary (result.error / empty hash -> TxBroadcastError). + */ + +import { HttpService } from 'src/shared/services/http.service'; +import { PayoutGroup } from 'src/subdomains/supporting/payout/services/base/payout-bitcoin-based.service'; +import { TxBroadcastError } from '../../shared/errors/tx-broadcast.error'; +import { MoneroClient } from '../monero-client'; + +jest.mock('src/config/config', () => { + const mockConfig = { + blockchain: { + monero: { + node: { url: 'https://monero-node.test' }, + rpc: { url: 'https://monero-rpc.test' }, + walletAddress: 'MONERO_WALLET_ADDR', + certificate: undefined, + }, + }, + }; + return { + Config: mockConfig, + GetConfig: () => mockConfig, + }; +}); + +describe('MoneroClient - broadcast boundary', () => { + let client: MoneroClient; + let mockPost: jest.Mock; + + beforeEach(() => { + mockPost = jest.fn(); + + const mockHttpService = { + post: mockPost, + get: jest.fn(), + } as unknown as jest.Mocked; + + client = new MoneroClient(mockHttpService); + }); + + const payout: PayoutGroup = [{ addressTo: 'MONERO_DEST_ADDR', amount: 1.5 }]; + + describe('sendTransfers(...)', () => { + it('returns the mapped transfer on a successful RPC response', async () => { + mockPost.mockResolvedValueOnce({ + result: { amount: 1500000000000, fee: 10000000000, tx_hash: 'TX_HASH_01' }, + }); + + const result = await client.sendTransfers(payout); + + expect(result.txid).toBe('TX_HASH_01'); + }); + + it('wraps a failure of the underlying HTTP call into a TxBroadcastError', async () => { + const httpError = new Error('socket hang up'); + mockPost.mockRejectedValueOnce(httpError); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('socket hang up'); + expect((error as TxBroadcastError).cause).toBe(httpError); + }); + + it('wraps a non-Error rejection into a TxBroadcastError via String(e)', async () => { + mockPost.mockRejectedValueOnce('gateway timeout'); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('gateway timeout'); + }); + + it('wraps an RPC-level error field (HTTP 200, JSON-RPC error) into a TxBroadcastError', async () => { + mockPost.mockResolvedValueOnce({ error: { code: -18, message: 'Failed to send tx' } }); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Failed to send tx'); + }); + + it('wraps a response with neither result nor error into a TxBroadcastError', async () => { + mockPost.mockResolvedValueOnce({}); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('No result after send transfer'); + }); + + it('wraps a malformed null body (throwing while reading result.error) into a TxBroadcastError, staying fail-closed', async () => { + // A null/undefined body would make mapSendTransfer throw a plain TypeError; since it now runs + // inside the boundary, that becomes a TxBroadcastError instead of a self-healing plain error. + mockPost.mockResolvedValueOnce(null); + + let error: unknown; + try { + await client.sendTransfers(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + }); + }); +}); diff --git a/src/integration/blockchain/monero/monero-client.ts b/src/integration/blockchain/monero/monero-client.ts index 4937a807b9..b83895a989 100644 --- a/src/integration/blockchain/monero/monero-client.ts +++ b/src/integration/blockchain/monero/monero-client.ts @@ -7,6 +7,7 @@ import { Util } from 'src/shared/utils/util'; import { PayoutGroup } from 'src/subdomains/supporting/payout/services/base/payout-bitcoin-based.service'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient, CoinOnly } from '../shared/util/blockchain-client'; import { AddressResultDto, @@ -239,9 +240,14 @@ export class MoneroClient extends BlockchainClient implements CoinOnly { return this.sendTransfers([{ addressTo: destinationAddress, amount }]); } + // Broadcast boundary: the wallet RPC's 'transfer' method builds, signs and relays the tx + // atomically in one call - there is no separate pre-broadcast step. A failure of the HTTP call + // itself, an RPC-level error field, or a missing result are all ambiguous (the wallet may have + // already relayed before the response was lost/rejected), mirroring the Solana sendTransaction + // boundary (result.error / empty hash -> TxBroadcastError). async sendTransfers(payout: PayoutGroup): Promise { - return this.http - .post( + try { + const result = await this.http.post( `${Config.blockchain.monero.rpc.url}/json_rpc`, { method: 'transfer', @@ -252,13 +258,21 @@ export class MoneroClient extends BlockchainClient implements CoinOnly { }, }, this.httpConfig(), - ) - .then((r) => this.mapSendTransfer(r)); + ); + + // Response mapping stays inside the boundary: a malformed/empty body throwing while reading + // result.error would otherwise be a plain error and self-heal a possibly-relayed transfer. + return this.mapSendTransfer(result); + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } private mapSendTransfer(sendTransferResult: GetSendTransferResultDto): MoneroTransferDto { - if (sendTransferResult.error) throw new Error(sendTransferResult.error.message); - if (!sendTransferResult.result) throw new Error('No result after send transfer'); + if (sendTransferResult.error) + throw new TxBroadcastError(sendTransferResult.error.message, { cause: sendTransferResult.error }); + if (!sendTransferResult.result) throw new TxBroadcastError('No result after send transfer'); return this.convertTransferAuToXmr({ amount: sendTransferResult.result.amount, diff --git a/src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts b/src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts new file mode 100644 index 0000000000..4bb5ac45c4 --- /dev/null +++ b/src/integration/blockchain/shared/errors/__tests__/tx-broadcast.error.spec.ts @@ -0,0 +1,20 @@ +import { TxBroadcastError } from '../tx-broadcast.error'; + +describe('TxBroadcastError', () => { + it('sets message and name', () => { + const error = new TxBroadcastError('broadcast failed'); + + expect(error.message).toBe('broadcast failed'); + expect(error.name).toBe('TxBroadcastError'); + expect(error).toBeInstanceOf(Error); + expect(error.cause).toBeUndefined(); + }); + + it('carries the cause through to the Error options', () => { + const cause = new Error('underlying RPC error'); + + const error = new TxBroadcastError('broadcast failed', { cause }); + + expect(error.cause).toBe(cause); + }); +}); diff --git a/src/integration/blockchain/shared/errors/tx-broadcast.error.ts b/src/integration/blockchain/shared/errors/tx-broadcast.error.ts new file mode 100644 index 0000000000..292a4968e7 --- /dev/null +++ b/src/integration/blockchain/shared/errors/tx-broadcast.error.ts @@ -0,0 +1,9 @@ +// Generic marker for a failure of the actual on-chain send call (wallet.sendTransaction / +// contract.transfer). Deliberately not payout-specific - this client is shared by dex, payin and +// faucet callers too, which keep catching plain Error via catch(e) and logging e.message. +export class TxBroadcastError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'TxBroadcastError'; + } +} diff --git a/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts new file mode 100644 index 0000000000..bfb1b5695d --- /dev/null +++ b/src/integration/blockchain/shared/evm/__tests__/evm-client.spec.ts @@ -0,0 +1,215 @@ +/** + * Focused unit tests for the EvmClient broadcast boundary: the two try/catch blocks around the + * actual on-chain send calls in `sendNativeCoin` (wallet.sendTransaction) and `sendToken` + * (contract.transfer), which translate any failure at/after the broadcast call into a + * TxBroadcastError. + * + * EvmClient is a large abstract class whose constructor wires up real ethers.js collaborators + * (StaticJsonRpcProvider, Wallet, AlphaRouter, ...) and pulls in the rest of its considerable + * surface (Uniswap swaps, Alchemy history, ...). None of that is relevant here and constructing a + * real instance would need live network config. To isolate JUST the two changed methods, we call + * the (protected/private) prototype methods directly via Function.prototype.call against a minimal + * stub `this`: a plain object created with `Object.create(EvmClient.prototype)` (so it still has + * EvmClient's prototype chain) with only the handful of collaborator methods the two methods under + * test depend on (getCurrentGasForCoinTransaction, getTokenGasLimitForContact, + * getRecommendedGasPrice, getNonce, setNonce, getTokenByContract) stubbed as own properties, which + * shadow the real prototype implementations. This exercises the real, unmodified method bodies + * without needing a live provider/wallet/router. + */ + +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { EvmClient } from '../evm-client'; + +const proto = EvmClient.prototype as any; + +interface ClientStub { + getCurrentGasForCoinTransaction: jest.Mock; + getTokenGasLimitForContact: jest.Mock; + getRecommendedGasPrice: jest.Mock; + getNonce: jest.Mock; + setNonce: jest.Mock; + getTokenByContract: jest.Mock; +} + +function createClientStub(): ClientStub { + const client = Object.create(EvmClient.prototype) as ClientStub; + + client.getCurrentGasForCoinTransaction = jest.fn().mockResolvedValue(21000); + client.getTokenGasLimitForContact = jest.fn().mockResolvedValue(65000); + client.getRecommendedGasPrice = jest.fn().mockResolvedValue(1_000_000_000); + client.getNonce = jest.fn().mockResolvedValue(5); + client.setNonce = jest.fn(); + client.getTokenByContract = jest.fn().mockResolvedValue({ decimals: 6 }); + + return client; +} + +describe('EvmClient - broadcast boundary', () => { + describe('sendNativeCoin(...)', () => { + it('broadcasts successfully, defaults to the current nonce and advances the cached nonce', async () => { + const client = createClientStub(); + const sendTransaction = jest.fn().mockResolvedValue({ hash: 'TX_HASH_01' }); + const wallet = { address: 'FROM_ADDR', sendTransaction }; + + const hash = await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1); + + expect(hash).toBe('TX_HASH_01'); + expect(sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'FROM_ADDR', + to: 'TO_ADDR', + nonce: 5, + gasPrice: 1_000_000_000, + gasLimit: 21000, + }), + ); + expect(client.setNonce).toHaveBeenCalledWith('FROM_ADDR', 6); // txNonce(5) >= currentNonce(5) + }); + + it('reuses an explicit lower nonce (speedup/retry) without advancing the cached nonce', async () => { + const client = createClientStub(); + const sendTransaction = jest.fn().mockResolvedValue({ hash: 'TX_HASH_02' }); + const wallet = { address: 'FROM_ADDR', sendTransaction }; + + const hash = await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1, 3); + + expect(hash).toBe('TX_HASH_02'); + expect(sendTransaction).toHaveBeenCalledWith(expect.objectContaining({ nonce: 3 })); + expect(client.setNonce).not.toHaveBeenCalled(); // txNonce(3) < currentNonce(5) + }); + + it('wraps an Error thrown by wallet.sendTransaction into a TxBroadcastError, preserving message and cause', async () => { + const client = createClientStub(); + const rpcError = new Error('nonce too low'); + const wallet = { address: 'FROM_ADDR', sendTransaction: jest.fn().mockRejectedValue(rpcError) }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('nonce too low'); + expect((error as TxBroadcastError).cause).toBe(rpcError); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('wraps a non-Error rejection from wallet.sendTransaction into a TxBroadcastError via String(e)', async () => { + const client = createClientStub(); + const wallet = { address: 'FROM_ADDR', sendTransaction: jest.fn().mockRejectedValue('RPC timeout') }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('RPC timeout'); + expect((error as TxBroadcastError).cause).toBe('RPC timeout'); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('treats an empty tx hash as a broadcast-boundary failure (fail-closed: the send call was reached)', async () => { + const client = createClientStub(); + const sendTransaction = jest.fn().mockResolvedValue({ hash: '' }); + const wallet = { address: 'FROM_ADDR', sendTransaction }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Broadcast returned an empty tx hash'); + expect(client.setNonce).not.toHaveBeenCalled(); // never advance the nonce on an unconfirmed broadcast + }); + }); + + describe('sendToken(...)', () => { + it('broadcasts successfully, defaults to the current nonce and advances the cached nonce', async () => { + const client = createClientStub(); + const transfer = jest.fn().mockResolvedValue({ hash: 'TX_HASH_03' }); + const contract = { transfer }; + + const hash = await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1); + + expect(hash).toBe('TX_HASH_03'); + expect(transfer).toHaveBeenCalledWith( + 'TO_ADDR', + expect.anything(), + expect.objectContaining({ nonce: 5, gasPrice: 1_000_000_000, gasLimit: 65000 }), + ); + expect(client.setNonce).toHaveBeenCalledWith('FROM_ADDR', 6); // txNonce(5) >= currentNonce(5) + }); + + it('reuses an explicit lower nonce (speedup/retry) without advancing the cached nonce', async () => { + const client = createClientStub(); + const transfer = jest.fn().mockResolvedValue({ hash: 'TX_HASH_04' }); + const contract = { transfer }; + + const hash = await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1, 3); + + expect(hash).toBe('TX_HASH_04'); + expect(transfer).toHaveBeenCalledWith('TO_ADDR', expect.anything(), expect.objectContaining({ nonce: 3 })); + expect(client.setNonce).not.toHaveBeenCalled(); // txNonce(3) < currentNonce(5) + }); + + it('wraps an Error thrown by contract.transfer into a TxBroadcastError, preserving message and cause', async () => { + const client = createClientStub(); + const rpcError = new Error('replacement transaction underpriced'); + const contract = { transfer: jest.fn().mockRejectedValue(rpcError) }; + + let error: unknown; + try { + await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('replacement transaction underpriced'); + expect((error as TxBroadcastError).cause).toBe(rpcError); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('wraps a non-Error rejection from contract.transfer into a TxBroadcastError via String(e)', async () => { + const client = createClientStub(); + const contract = { transfer: jest.fn().mockRejectedValue('RPC timeout') }; + + let error: unknown; + try { + await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('RPC timeout'); + expect((error as TxBroadcastError).cause).toBe('RPC timeout'); + expect(client.setNonce).not.toHaveBeenCalled(); + }); + + it('treats an empty tx hash as a broadcast-boundary failure (fail-closed: the send call was reached)', async () => { + const client = createClientStub(); + const transfer = jest.fn().mockResolvedValue({ hash: '' }); + const contract = { transfer }; + + let error: unknown; + try { + await proto.sendToken.call(client, contract, 'FROM_ADDR', 'TO_ADDR', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Broadcast returned an empty tx hash'); + expect(client.setNonce).not.toHaveBeenCalled(); // never advance the nonce on an unconfirmed broadcast + }); + }); +}); diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 5530a984d6..9e03aa8c54 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -10,6 +10,7 @@ import { Contract, BigNumber as EthersNumber, ethers } from 'ethers'; import { hashMessage } from 'ethers/lib/utils'; import { AlchemyService, AssetTransfersParams } from 'src/integration/alchemy/services/alchemy.service'; import { BlockscoutService } from 'src/integration/blockchain/shared/blockscout/blockscout.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import ERC1271_ABI from 'src/integration/blockchain/shared/evm/abi/erc1271.abi.json'; import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; import SIGNATURE_TRANSFER_ABI from 'src/integration/blockchain/shared/evm/abi/signature-transfer.abi.json'; @@ -826,14 +827,21 @@ export abstract class EvmClient extends BlockchainClient { const currentNonce = await this.getNonce(fromAddress); const txNonce = nonce ?? currentNonce; - const tx = await wallet.sendTransaction({ - from: fromAddress, - to: toAddress, - value: EvmUtil.toWeiAmount(amount), - nonce: txNonce, - gasPrice, - gasLimit, - }); + let tx: ethers.providers.TransactionResponse; + try { + tx = await wallet.sendTransaction({ + from: fromAddress, + to: toAddress, + value: EvmUtil.toWeiAmount(amount), + nonce: txNonce, + gasPrice, + gasLimit, + }); + + if (!tx?.hash) throw new Error('Broadcast returned an empty tx hash'); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } if (txNonce >= currentNonce) this.setNonce(fromAddress, txNonce + 1); @@ -863,7 +871,14 @@ export abstract class EvmClient extends BlockchainClient { const currentNonce = await this.getNonce(fromAddress); const txNonce = nonce ?? currentNonce; - const tx = await contract.transfer(toAddress, targetAmount, { gasPrice, gasLimit, nonce: txNonce }); + let tx: ethers.ContractTransaction; + try { + tx = await contract.transfer(toAddress, targetAmount, { gasPrice, gasLimit, nonce: txNonce }); + + if (!tx?.hash) throw new Error('Broadcast returned an empty tx hash'); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } if (txNonce >= currentNonce) this.setNonce(fromAddress, txNonce + 1); diff --git a/src/integration/blockchain/solana/__tests__/solana-client.spec.ts b/src/integration/blockchain/solana/__tests__/solana-client.spec.ts new file mode 100644 index 0000000000..626ce04c68 --- /dev/null +++ b/src/integration/blockchain/solana/__tests__/solana-client.spec.ts @@ -0,0 +1,125 @@ +/** + * Focused unit test for the SolanaClient broadcast boundary: `sendTransaction` signs and serializes + * the transaction locally (pre-broadcast, provably safe to treat as a plain error), then calls + * `sendSignedTransaction` to relay it over RPC with skipPreflight - once that call has been made, a + * returned error is ambiguous (the node may already have accepted/relayed the tx), so it is + * translated into a TxBroadcastError. + * + * SolanaClient's constructor wires up a real Connection/SolanaWallet that need live config. To + * isolate JUST the changed method, we call the private prototype method directly via + * Function.prototype.call against a minimal stub `this`, following the same pattern as + * evm-client.spec.ts. + */ + +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { SolanaClient } from '../solana-client'; + +const proto = SolanaClient.prototype as any; + +function createClientStub(sendSignedTransaction: jest.Mock): any { + const client = Object.create(SolanaClient.prototype); + + client.sendSignedTransaction = sendSignedTransaction; + + return client; +} + +function createTransactionStub(): any { + return { serialize: jest.fn().mockReturnValue(Buffer.from('DEADBEEF', 'hex')) }; +} + +describe('SolanaClient - broadcast boundary', () => { + describe('sendTransaction(...)', () => { + it('signs, serializes and returns the tx hash when the RPC call succeeds', async () => { + const sendSignedTransaction = jest.fn().mockResolvedValue({ hash: 'TX_HASH_01' }); + const client = createClientStub(sendSignedTransaction); + const wallet = { signTransaction: jest.fn() }; + const transaction = createTransactionStub(); + + const hash = await proto.sendTransaction.call(client, wallet, transaction); + + expect(hash).toBe('TX_HASH_01'); + expect(wallet.signTransaction).toHaveBeenCalledWith(transaction); + expect(sendSignedTransaction).toHaveBeenCalledWith('deadbeef'); + }); + + it('wraps a missing tx hash (HTTP 200, no result.error) into a TxBroadcastError (fail-closed: request already sent)', async () => { + const result = { hash: undefined }; + const sendSignedTransaction = jest.fn().mockResolvedValue(result); + const client = createClientStub(sendSignedTransaction); + const wallet = { signTransaction: jest.fn() }; + const transaction = createTransactionStub(); + + let error: unknown; + try { + await proto.sendTransaction.call(client, wallet, transaction); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Broadcast returned an empty tx hash'); + expect((error as TxBroadcastError).cause).toBe(result); + }); + + it('wraps an empty-string tx hash (HTTP 200, no result.error) into a TxBroadcastError (fail-closed: request already sent)', async () => { + const result = { hash: '' }; + const sendSignedTransaction = jest.fn().mockResolvedValue(result); + const client = createClientStub(sendSignedTransaction); + const wallet = { signTransaction: jest.fn() }; + const transaction = createTransactionStub(); + + let error: unknown; + try { + await proto.sendTransaction.call(client, wallet, transaction); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Broadcast returned an empty tx hash'); + }); + + it('wraps an RPC error returned by sendSignedTransaction into a TxBroadcastError (fail-closed: request already sent)', async () => { + const rpcError = { code: -32002, message: 'Transaction simulation failed' }; + const sendSignedTransaction = jest.fn().mockResolvedValue({ error: rpcError }); + const client = createClientStub(sendSignedTransaction); + const wallet = { signTransaction: jest.fn() }; + const transaction = createTransactionStub(); + + let error: unknown; + try { + await proto.sendTransaction.call(client, wallet, transaction); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Transaction simulation failed'); + expect((error as TxBroadcastError).cause).toBe(rpcError); + }); + + it('does not wrap a failure from wallet.signTransaction (local, pre-broadcast, plain error propagates unchanged)', async () => { + const signError = new Error('invalid keypair'); + const sendSignedTransaction = jest.fn(); + const client = createClientStub(sendSignedTransaction); + const wallet = { + signTransaction: jest.fn().mockImplementation(() => { + throw signError; + }), + }; + const transaction = createTransactionStub(); + + let error: unknown; + try { + await proto.sendTransaction.call(client, wallet, transaction); + } catch (e) { + error = e; + } + + expect(error).toBe(signError); // same object - not wrapped + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(sendSignedTransaction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/integration/blockchain/solana/solana-client.ts b/src/integration/blockchain/solana/solana-client.ts index 482166bc1d..23301669fe 100644 --- a/src/integration/blockchain/solana/solana-client.ts +++ b/src/integration/blockchain/solana/solana-client.ts @@ -1,6 +1,7 @@ import * as SolanaToken from '@solana/spl-token'; import * as Solana from '@solana/web3.js'; import { Config, GetConfig } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpService } from 'src/shared/services/http.service'; import { AsyncCache } from 'src/shared/utils/async-cache'; @@ -128,8 +129,13 @@ export class SolanaClient extends BlockchainClient { const hexTransaction = transaction.serialize().toString('hex'); + // Broadcast boundary: the RPC call has already been made at this point, so a rejection here is + // ambiguous (skipPreflight means the node may have accepted and relayed the tx before erroring). const result = await this.sendSignedTransaction(hexTransaction); - if (result.error) throw new Error(result.error.message); + if (result.error) throw new TxBroadcastError(result.error.message, { cause: result.error }); + // A JSON-RPC response with HTTP 200 can still omit the hash (falsy result.error) - fail-closed + // explicitly instead of relying on pendingPayout(undefined) to reject implicitly downstream. + if (!result.hash) throw new TxBroadcastError('Broadcast returned an empty tx hash', { cause: result }); return result.hash; } diff --git a/src/integration/blockchain/spark/__tests__/spark-client.spec.ts b/src/integration/blockchain/spark/__tests__/spark-client.spec.ts new file mode 100644 index 0000000000..798c858c07 --- /dev/null +++ b/src/integration/blockchain/spark/__tests__/spark-client.spec.ts @@ -0,0 +1,164 @@ +/** + * Focused unit test for the SparkClient broadcast boundary: sendTransaction syncs leaves (retryable, + * idempotent - routed through this.call()) and THEN calls wallet.transfer, which signs AND sends + * atomically inside the SDK. wallet.transfer must never be retried by this.call()'s reconnect-and-retry + * (which would resend an already-settled transfer): a failure at or after that call is wrapped into a + * TxBroadcastError instead. Wallet resolution / leaf-sync failures never reach wallet.transfer and stay + * plain errors (safe to retry through this.call()). + */ + +import { SparkWallet } from '@buildonspark/spark-sdk'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { SparkClient } from '../spark-client'; + +jest.mock('@buildonspark/spark-sdk', () => ({ + SparkWallet: { initialize: jest.fn() }, +})); + +jest.mock('src/config/config', () => ({ + GetConfig: () => ({ + blockchain: { + spark: { + sparkWalletSeed: 'test seed phrase', + }, + }, + }), +})); + +function emptyAsyncGenerator(): AsyncGenerator { + return (async function* () {})(); +} + +describe('SparkClient', () => { + let client: SparkClient; + let mockWallet: { + getSparkAddress: jest.Mock; + getBalance: jest.Mock; + transfer: jest.Mock; + getTransfer: jest.Mock; + optimizeLeaves: jest.Mock; + optimizeTokenOutputs: jest.Mock; + on: jest.Mock; + }; + + beforeEach(() => { + mockWallet = { + getSparkAddress: jest.fn().mockResolvedValue('spark1testwalletaddress'), + getBalance: jest.fn().mockResolvedValue({ balance: 50000n }), + transfer: jest.fn().mockResolvedValue({ id: 'tx-abc123' }), + getTransfer: jest.fn(), + optimizeLeaves: jest.fn().mockImplementation(() => emptyAsyncGenerator()), + optimizeTokenOutputs: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + }; + + // SparkWallet.initialize is a bare jest.fn() from the module factory mock (not a real method), so + // jest.restoreAllMocks() cannot reset its call history the way it does for a genuine jest.spyOn + // target - clear it explicitly every test to keep call-count assertions isolated per test. + (SparkWallet.initialize as jest.Mock).mockClear(); + jest.spyOn(SparkWallet, 'initialize').mockResolvedValue({ wallet: mockWallet as any } as any); + + client = new SparkClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendTransaction', () => { + it('should convert BTC amount to satoshis and return txid', async () => { + const result = await client.sendTransaction('spark1destination', 0.5); + + expect(mockWallet.transfer).toHaveBeenCalledWith({ + amountSats: 50_000_000, + receiverSparkAddress: 'spark1destination', + }); + expect(result).toEqual({ txid: 'tx-abc123', fee: 0 }); + }); + }); + + // --- SEND TRANSACTION - BROADCAST BOUNDARY --- // + + describe('sendTransaction - broadcast boundary', () => { + it('wraps a wallet.transfer failure into a TxBroadcastError, keeping message and cause', async () => { + const cause = new Error('unexpected rejection'); + mockWallet.transfer.mockRejectedValue(cause); + + let error: unknown; + try { + await client.sendTransaction('spark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('unexpected rejection'); + expect((error as TxBroadcastError).cause).toBe(cause); + }); + + it('wraps a non-Error rejection using String(e)', async () => { + mockWallet.transfer.mockRejectedValue('plain string rejection'); + + let error: unknown; + try { + await client.sendTransaction('spark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('plain string rejection'); + }); + + it('never resends when wallet.transfer fails with a channel-shutdown error (fail-closed, no double-send), but resets the cache so the next call reconnects', async () => { + mockWallet.transfer.mockRejectedValue(new Error('Channel has been shut down')); + + await expect(client.sendTransaction('spark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.transfer).toHaveBeenCalledTimes(1); // never resent within this call + + const freshWallet = { ...mockWallet, transfer: jest.fn().mockResolvedValue({ id: 'tx-fresh' }) }; + jest.spyOn(SparkWallet, 'initialize').mockResolvedValue({ wallet: freshWallet as any } as any); + + const result = await client.sendTransaction('spark1destination', 0.5); + + expect(result).toEqual({ txid: 'tx-fresh', fee: 0 }); // next call picked up the fresh wallet + expect(freshWallet.transfer).toHaveBeenCalledTimes(1); + }); + + it('does not reset the cache when wallet.transfer fails with an unrelated error', async () => { + mockWallet.transfer.mockRejectedValue(new Error('insufficient funds')); + + await expect(client.sendTransaction('spark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + + mockWallet.transfer.mockResolvedValue({ id: 'tx-retry' }); + await client.sendTransaction('spark1destination', 0.5); + + // still the same wallet instance from SparkWallet.initialize - no reinitialization happened + expect(SparkWallet.initialize).toHaveBeenCalledTimes(1); + }); + + it('propagates a leaf-sync (pre-broadcast) failure unchanged and never reaches wallet.transfer', async () => { + const syncError = new Error('optimizeLeaves failed'); + mockWallet.optimizeLeaves.mockImplementation(() => { + throw syncError; + }); + + let error: unknown; + try { + await client.sendTransaction('spark1destination', 0.5); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.transfer).not.toHaveBeenCalled(); + }); + + it('wraps an empty transfer id into a TxBroadcastError (fail-closed silent failure)', async () => { + mockWallet.transfer.mockResolvedValue({ id: '' }); + + await expect(client.sendTransaction('spark1destination', 0.5)).rejects.toBeInstanceOf(TxBroadcastError); + expect(mockWallet.transfer).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/integration/blockchain/spark/spark-client.ts b/src/integration/blockchain/spark/spark-client.ts index df4e07859a..cd06ccdff0 100644 --- a/src/integration/blockchain/spark/spark-client.ts +++ b/src/integration/blockchain/spark/spark-client.ts @@ -5,6 +5,7 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { AsyncField } from 'src/shared/utils/async-field'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient } from '../shared/util/blockchain-client'; export interface SparkTransaction { @@ -84,18 +85,41 @@ export class SparkClient extends BlockchainClient { // --- TRANSACTION METHODS --- // async sendTransaction(to: string, amount: number): Promise<{ txid: string; fee: number }> { - return this.call(async (wallet) => { - const amountSats = Math.round(amount * 1e8); + // Pre-broadcast: resolving the wallet and syncing leaves never calls wallet.transfer, so a + // failure here is provably pre-broadcast - routed through this.call() so a channel-shutdown + // error still gets its reconnect-and-retry (safe: transfer was never reached yet). + const wallet = await this.call(async (w) => { + await this.syncLeaves(w); + return w; + }); - await this.syncLeaves(wallet); + const amountSats = Math.round(amount * 1e8); + // Broadcast boundary: wallet.transfer signs AND sends atomically inside the SDK - there is no + // separate pre-broadcast step to isolate, so any failure from here on is ambiguous (the transfer + // may already be in-flight/settled on Spark's side). Deliberately NOT routed through + // this.call()'s reconnect-and-retry: that wrapper re-invokes the whole operation on a + // channel-shutdown error, which for a transfer would risk sending twice. Reset the cache on such + // an error so only the *next* call reconnects, without resending this one. + try { const result = await wallet.transfer({ amountSats, receiverSparkAddress: to, }); + // An empty transfer id from the SDK is an ambiguous silent failure - fail-closed rather than + // returning it and letting the empty id roll the order back for re-broadcast. + if (!result?.id) throw new TxBroadcastError('Spark broadcast returned an empty transfer id'); + return { txid: result.id, fee: 0 }; - }); + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + if (e?.message?.includes('Channel has been shut down')) { + this.wallet.reset(); + this.cachedAddress.reset(); + } + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async getTransaction(txId: string): Promise { diff --git a/src/integration/blockchain/tron/__tests__/tron-client.spec.ts b/src/integration/blockchain/tron/__tests__/tron-client.spec.ts new file mode 100644 index 0000000000..0493d1c19b --- /dev/null +++ b/src/integration/blockchain/tron/__tests__/tron-client.spec.ts @@ -0,0 +1,183 @@ +/** + * Focused unit test for the TronClient broadcast boundary: sendNativeCoin/sendToken forward a single + * gateway call (`POST /transaction` / `POST /trc20/transaction`) that signs AND broadcasts the tx + * atomically in one remote request - there is no separate local pre-broadcast step to isolate, so + * ANY failure from that POST call on is ambiguous (the tx may already be on-chain) and gets wrapped + * into a TxBroadcastError. sendToken's decimals check happens before the POST and stays a plain, + * provably pre-broadcast error. + * + * TronClient's constructor wires up a real TronWallet from a seed that needs live config. To isolate + * JUST the changed methods, we call the private prototype methods directly via Function.prototype.call + * against a minimal stub `this`, following the same pattern as cardano/solana/icp-client.spec.ts. + */ + +import { ConfigService } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { TronClient } from '../tron-client'; + +const proto = TronClient.prototype as any; + +function createClientStub(post: jest.Mock): any { + const client = Object.create(TronClient.prototype); + client.http = { post }; + return client; +} + +describe('TronClient - broadcast boundary', () => { + beforeAll(() => { + new ConfigService(); // sets module-level Config (tronApiUrl/tronApiKey read inside sendNativeCoin/sendToken) + }); + + describe('sendNativeCoin(...)', () => { + it('returns the txId when the gateway call succeeds', async () => { + const post = jest.fn().mockResolvedValue({ txId: 'TX_HASH_01' }); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + const txId = await proto.sendNativeCoin.call(client, wallet, 'ADDR_01', 5); + + expect(txId).toBe('TX_HASH_01'); + expect(post).toHaveBeenCalledTimes(1); + expect(post.mock.calls[0][1]).toEqual({ + fromPrivateKey: 'PRIVATE_KEY_HEX', + to: 'ADDR_01', + amount: '5', + }); + }); + + it('wraps a gateway rejection into a TxBroadcastError (fail-closed: sign+broadcast are atomic)', async () => { + const cause = new Error('gateway unreachable'); + const post = jest.fn().mockRejectedValue(cause); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'ADDR_01', 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('gateway unreachable'); + expect((error as TxBroadcastError).cause).toBe(cause); + }); + + it('wraps a non-Error rejection using String(e)', async () => { + const post = jest.fn().mockRejectedValue('plain string rejection'); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'ADDR_01', 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('plain string rejection'); + }); + + it('wraps a 200 response without a txId into a TxBroadcastError (fail-closed silent failure)', async () => { + const post = jest.fn().mockResolvedValue({}); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendNativeCoin.call(client, wallet, 'ADDR_01', 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Tron broadcast returned an empty txId'); + }); + }); + + describe('sendToken(...)', () => { + const token = createCustomAsset({ decimals: 6, chainId: '0xTOKENADDR', uniqueName: 'Tron/USDT' }); + + it('throws a plain (non-TxBroadcastError) Error when the token has no decimals - pre-broadcast, never reaches the gateway', async () => { + const post = jest.fn(); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + const tokenWithoutDecimals = createCustomAsset({ decimals: undefined, uniqueName: 'Tron/BAD' }); + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'ADDR_01', tokenWithoutDecimals, 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toBe('No decimals found in token Tron/BAD'); + expect(post).not.toHaveBeenCalled(); + }); + + it('returns the txId when the gateway call succeeds', async () => { + const post = jest.fn().mockResolvedValue({ txId: 'TX_HASH_02' }); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + const txId = await proto.sendToken.call(client, wallet, 'ADDR_01', token, 5); + + expect(txId).toBe('TX_HASH_02'); + expect(post).toHaveBeenCalledTimes(1); + }); + + it('wraps a gateway rejection into a TxBroadcastError (fail-closed: sign+broadcast are atomic)', async () => { + const cause = new Error('gateway rejected the trc20 tx'); + const post = jest.fn().mockRejectedValue(cause); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'ADDR_01', token, 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('gateway rejected the trc20 tx'); + expect((error as TxBroadcastError).cause).toBe(cause); + }); + + it('wraps a non-Error rejection using String(e)', async () => { + const post = jest.fn().mockRejectedValue('plain string rejection'); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'ADDR_01', token, 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('plain string rejection'); + }); + + it('wraps a 200 response without a txId into a TxBroadcastError (fail-closed silent failure)', async () => { + const post = jest.fn().mockResolvedValue({}); + const client = createClientStub(post); + const wallet = { privateKey: 'PRIVATE_KEY_HEX' }; + + let error: unknown; + try { + await proto.sendToken.call(client, wallet, 'ADDR_01', token, 5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('Tron broadcast returned an empty txId'); + }); + }); +}); diff --git a/src/integration/blockchain/tron/tron-client.ts b/src/integration/blockchain/tron/tron-client.ts index cf3ffb310c..9ef8333aa9 100644 --- a/src/integration/blockchain/tron/tron-client.ts +++ b/src/integration/blockchain/tron/tron-client.ts @@ -1,4 +1,5 @@ import { Config } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpRequestConfig, HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; @@ -195,8 +196,11 @@ export class TronClient extends BlockchainClient { private async sendNativeCoin(wallet: TronWallet, toAddress: string, amount: number): Promise { const url = Config.blockchain.tron.tronApiUrl; - return this.http - .post( + // Broadcast boundary: this gateway call signs AND broadcasts the tx atomically in a single + // remote call - there is no separate local pre-broadcast step to isolate, so any failure from + // here on is ambiguous (the tx may already be on-chain). + try { + const response = await this.http.post( `${url}/transaction`, { fromPrivateKey: wallet.privateKey, @@ -204,8 +208,17 @@ export class TronClient extends BlockchainClient { amount: amount.toString(), }, this.httpConfig(), - ) - .then((r) => r.txId); + ); + + // A 200 response without a txId is an in-band silent failure - treat it as ambiguous + // (fail-closed) rather than returning an empty id that would later roll back for re-broadcast. + if (!response?.txId) throw new TxBroadcastError('Tron broadcast returned an empty txId', { cause: response }); + + return response.txId; + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async sendTokenFromAccount(account: WalletAccount, toAddress: string, token: Asset, amount: number): Promise { @@ -218,12 +231,14 @@ export class TronClient extends BlockchainClient { } private async sendToken(wallet: TronWallet, toAddress: string, token: Asset, amount: number): Promise { + // Pre-broadcast: local validation only, no network call - stays a plain Error. if (!token.decimals) throw new Error(`No decimals found in token ${token.uniqueName}`); const url = Config.blockchain.tron.tronApiUrl; - return this.http - .post( + // Broadcast boundary: see sendNativeCoin. + try { + const response = await this.http.post( `${url}/trc20/transaction`, { fromPrivateKey: wallet.privateKey, @@ -233,8 +248,16 @@ export class TronClient extends BlockchainClient { feeLimit: TronUtil.fromSunAmount(Config.blockchain.tron.sendTokenFeeLimit), }, this.httpConfig(), - ) - .then((r) => r.txId); + ); + + // See sendNativeCoin: a 200 without a txId is an ambiguous in-band failure, not an empty success. + if (!response?.txId) throw new TxBroadcastError('Tron broadcast returned an empty txId', { cause: response }); + + return response.txId; + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async sendSignedTransaction(tx: string): Promise { diff --git a/src/integration/blockchain/zano/__test__/zano-client.spec.ts b/src/integration/blockchain/zano/__test__/zano-client.spec.ts new file mode 100644 index 0000000000..35169c70f3 --- /dev/null +++ b/src/integration/blockchain/zano/__test__/zano-client.spec.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for ZanoClient's broadcast boundary. + * + * The wallet RPC's 'transfer' method builds, signs and relays a Zano transaction atomically in + * one call - there is no separate pre-broadcast step to exclude (the pre-broadcast balance checks + * in sendCoins/sendTokens are separate 'getbalance' RPC calls). A failure of the HTTP call itself + * or a response missing tx_details are both ambiguous (the wallet may have already relayed before + * the response was lost/rejected) and must surface as TxBroadcastError, mirroring the Solana + * sendTransaction boundary (result.error / empty hash -> TxBroadcastError). + */ + +import { HttpService } from 'src/shared/services/http.service'; +import { PayoutGroup } from 'src/subdomains/supporting/payout/services/base/payout-bitcoin-based.service'; +import { TxBroadcastError } from '../../shared/errors/tx-broadcast.error'; +import { ZanoClient } from '../zano-client'; + +jest.mock('src/config/config', () => { + const mockConfig = { + blockchain: { + zano: { + node: { url: 'https://zano-node.test' }, + wallet: { url: 'https://zano-wallet.test', address: 'ZANO_WALLET_ADDR' }, + coinId: 'd6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a', + fee: 0.01, + }, + }, + }; + return { + Config: mockConfig, + GetConfig: () => mockConfig, + }; +}); + +describe('ZanoClient - broadcast boundary', () => { + let client: ZanoClient; + let mockPost: jest.Mock; + + beforeEach(() => { + mockPost = jest.fn().mockImplementation((_url, params) => { + if (params.method === 'getbalance') { + // 100 ZANO unlocked, comfortably above payout amount + fee + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + } + return Promise.resolve({ result: { tx_details: { tx_hash: 'TX_HASH_01' } } }); + }); + + const mockHttpService = { + post: mockPost, + get: jest.fn(), + } as unknown as jest.Mocked; + + client = new ZanoClient(mockHttpService); + }); + + const payout: PayoutGroup = [{ addressTo: 'ZANO_DEST_ADDR', amount: 1.5 }]; + + describe('sendCoins(...)', () => { + it('returns the tx id on a successful transfer', async () => { + const result = await client.sendCoins(payout); + + expect(result.txId).toBe('TX_HASH_01'); + }); + + it('wraps a failure of the underlying HTTP transfer call into a TxBroadcastError', async () => { + const httpError = new Error('socket hang up'); + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.reject(httpError); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('socket hang up'); + expect((error as TxBroadcastError).cause).toBe(httpError); + }); + + it('wraps a non-Error rejection of the transfer call into a TxBroadcastError via String(e)', async () => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.reject('gateway timeout'); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('gateway timeout'); + }); + + it('wraps a transfer response missing tx_details into a TxBroadcastError', async () => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.resolve({ result: {} }); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toContain('Transfer not sent'); + }); + + it('wraps a malformed null transfer body (throwing while reading response.result) into a TxBroadcastError, staying fail-closed', async () => { + // A null/undefined body would make createSendTransferResult throw a plain TypeError; since it + // now runs inside the boundary, that becomes a TxBroadcastError instead of a self-healing error. + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 100e12, unlocked_balance: 100e12, balances: [] } }); + return Promise.resolve(null); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + }); + + it('does not wrap a pre-broadcast insufficient-balance failure (plain Error propagates unchanged)', async () => { + mockPost.mockImplementation((_url, params) => { + if (params.method === 'getbalance') + return Promise.resolve({ result: { balance: 0, unlocked_balance: 0, balances: [] } }); + return Promise.resolve({ result: { tx_details: { tx_hash: 'TX_HASH_01' } } }); + }); + + let error: unknown; + try { + await client.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).not.toBeInstanceOf(TxBroadcastError); + expect((error as Error).message).toContain('Unlocked coin balance'); + }); + }); +}); diff --git a/src/integration/blockchain/zano/zano-client.ts b/src/integration/blockchain/zano/zano-client.ts index 5a138211f1..7d5717a55c 100644 --- a/src/integration/blockchain/zano/zano-client.ts +++ b/src/integration/blockchain/zano/zano-client.ts @@ -7,6 +7,7 @@ import { Util } from 'src/shared/utils/util'; import { PayoutGroup } from 'src/subdomains/supporting/payout/services/base/payout-bitcoin-based.service'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { TxBroadcastError } from '../shared/errors/tx-broadcast.error'; import { BlockchainClient, BlockchainToken } from '../shared/util/blockchain-client'; import { ZanoAddressDto, @@ -251,6 +252,11 @@ export class ZanoClient extends BlockchainClient { return this.doSendTransfer(payout, payoutAmount, token.decimals, token.chainId); } + // Broadcast boundary: the wallet RPC's 'transfer' method builds, signs and relays the tx + // atomically in one call - there is no separate pre-broadcast step. A failure of the HTTP call + // itself or a response missing tx_details are both ambiguous (the wallet may have already + // relayed before the response was lost/rejected), mirroring the Solana sendTransaction boundary + // (result.error / empty hash -> TxBroadcastError). private async doSendTransfer( payout: PayoutGroup, payoutAmount: number, @@ -271,15 +277,23 @@ export class ZanoClient extends BlockchainClient { service_entries: [], }); - return this.http - .post<{ + try { + const response = await this.http.post<{ result: { tx_details: { tx_hash: string } }; - }>(`${Config.blockchain.zano.wallet.url}/json_rpc`, transferParams) - .then((r) => this.createSendTransferResult(payoutAmount, r)); + }>(`${Config.blockchain.zano.wallet.url}/json_rpc`, transferParams); + + // Response mapping stays inside the boundary: a malformed/empty body throwing while reading + // response.result would otherwise be a plain error and self-heal a possibly-relayed transfer. + return this.createSendTransferResult(payoutAmount, response); + } catch (e) { + if (e instanceof TxBroadcastError) throw e; + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } private createSendTransferResult(payoutAmount: number, response?: any): ZanoSendTransferResultDto { - if (!response.result?.tx_details) throw new Error(`Transfer not sent: response was ${JSON.stringify(response)}`); + if (!response.result?.tx_details) + throw new TxBroadcastError(`Transfer not sent: response was ${JSON.stringify(response)}`); return { txId: response.result.tx_details.tx_hash, diff --git a/src/integration/lightning/__tests__/lightning-client.spec.ts b/src/integration/lightning/__tests__/lightning-client.spec.ts new file mode 100644 index 0000000000..67a8fb2b38 --- /dev/null +++ b/src/integration/lightning/__tests__/lightning-client.spec.ts @@ -0,0 +1,137 @@ +/** + * Focused unit test for the LightningClient broadcast boundary: the try/catch tightly wrapping the + * actual LND send-payment POST (sendPaymentByInvoice / sendPaymentByPublicKey), which translates a + * transport/timeout failure into a TxBroadcastError - the request may already have reached LND + * before the connection dropped. An in-band payment failure (LND responds 200 with payment_error + * set) is deliberately NOT this layer's concern: it resolves normally here and is turned into a + * plain, self-healing error one layer up, in LightningService#sendTransfer (see + * lightning.service.ts), since LND has then provably NOT routed the payment. + */ + +import { mock } from 'jest-mock-extended'; +import { ConfigService } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { HttpService } from 'src/shared/services/http.service'; +import { LightningClient } from '../lightning-client'; + +describe('LightningClient - broadcast boundary', () => { + let http: HttpService; + let client: LightningClient; + let postSpy: jest.SpyInstance; + + beforeAll(() => { + new ConfigService(); // sets module-level Config (LND api URL + TLS agent construction) + }); + + beforeEach(() => { + http = mock(); + postSpy = jest.spyOn(http, 'post'); + client = new LightningClient(http); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendPaymentByInvoice(...)', () => { + it('returns the LND response on a successful POST', async () => { + const response = { payment_hash: 'aGFzaA==', payment_error: '' }; + postSpy.mockResolvedValue(response); + + const result = await client.sendPaymentByInvoice('lnbc1invoice'); + + expect(result).toBe(response); + }); + + it('does NOT wrap an in-band payment_error response - it resolves normally, letting the caller (LightningService#sendTransfer) throw a plain, self-healing error', async () => { + const response = { payment_hash: '', payment_error: 'FAILURE_REASON_NO_ROUTE' }; + postSpy.mockResolvedValue(response); + + const result = await client.sendPaymentByInvoice('lnbc1invoice'); + + expect(result).toBe(response); // no throw here - in-band failure is not this layer's concern + }); + + it('wraps an Error thrown by the HTTP POST into a TxBroadcastError, preserving message and cause (fail-closed: request already sent)', async () => { + const transportError = new Error('socket hang up'); + postSpy.mockRejectedValue(transportError); + + let error: unknown; + try { + await client.sendPaymentByInvoice('lnbc1invoice'); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('socket hang up'); + expect((error as TxBroadcastError).cause).toBe(transportError); + }); + + it('wraps a non-Error rejection from the HTTP POST into a TxBroadcastError via String(e)', async () => { + postSpy.mockRejectedValue('ETIMEDOUT'); + + let error: unknown; + try { + await client.sendPaymentByInvoice('lnbc1invoice'); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('ETIMEDOUT'); + expect((error as TxBroadcastError).cause).toBe('ETIMEDOUT'); + }); + }); + + describe('sendPaymentByPublicKey(...)', () => { + it('returns the LND response on a successful POST', async () => { + const response = { payment_hash: 'aGFzaA==', payment_error: '' }; + postSpy.mockResolvedValue(response); + + const result = await client.sendPaymentByPublicKey('02aabbccddeeff', 0.001); + + expect(result).toBe(response); + }); + + it('does NOT wrap an in-band payment_error response - it resolves normally, letting the caller (LightningService#sendTransfer) throw a plain, self-healing error', async () => { + const response = { payment_hash: '', payment_error: 'FAILURE_REASON_NO_ROUTE' }; + postSpy.mockResolvedValue(response); + + const result = await client.sendPaymentByPublicKey('02aabbccddeeff', 0.001); + + expect(result).toBe(response); + }); + + it('wraps an Error thrown by the HTTP POST into a TxBroadcastError, preserving message and cause (fail-closed: request already sent)', async () => { + const transportError = new Error('ECONNRESET'); + postSpy.mockRejectedValue(transportError); + + let error: unknown; + try { + await client.sendPaymentByPublicKey('02aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('ECONNRESET'); + expect((error as TxBroadcastError).cause).toBe(transportError); + }); + + it('wraps a non-Error rejection from the HTTP POST into a TxBroadcastError via String(e)', async () => { + postSpy.mockRejectedValue('ETIMEDOUT'); + + let error: unknown; + try { + await client.sendPaymentByPublicKey('02aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(TxBroadcastError); + expect((error as TxBroadcastError).message).toBe('ETIMEDOUT'); + expect((error as TxBroadcastError).cause).toBe('ETIMEDOUT'); + }); + }); +}); diff --git a/src/integration/lightning/lightning-client.ts b/src/integration/lightning/lightning-client.ts index 359fedb0d6..d2f12682f6 100644 --- a/src/integration/lightning/lightning-client.ts +++ b/src/integration/lightning/lightning-client.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { Agent } from 'https'; import { Config } from 'src/config/config'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { HttpError, HttpRequestConfig, HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; import { LnBitsWalletDto, LnBitsWalletPaymentDto, LnBitsWalletPaymentParamsDto } from './dto/lnbits.dto'; @@ -147,28 +148,42 @@ export class LightningClient implements CoinOnly { } async sendPaymentByInvoice(invoice: string): Promise { - return this.http.post( - `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, - { payment_request: invoice }, - this.httpLndConfig(), - ); + // Broadcast boundary: once this POST is sent, a transport/timeout failure is ambiguous - LND may + // have already dispatched the payment before the response reached us. A response.payment_error + // (checked by the caller, LightningService#sendTransfer) is the in-band, provably-not-routed + // case and is deliberately NOT covered here - it surfaces as a plain resolved value, not a + // rejection, so it never reaches this catch. + try { + return await this.http.post( + `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, + { payment_request: invoice }, + this.httpLndConfig(), + ); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } async sendPaymentByPublicKey(publicKey: string, amount: number): Promise { const preImage = randomBytes(32); const paymentHash = Util.createHash(preImage, 'sha256', 'base64'); - return this.http.post( - `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, - { - dest: Buffer.from(publicKey, 'hex').toString('base64'), - amt: Math.round(LightningHelper.btcToSat(amount)), - final_cltv_delta: 0, - payment_hash: paymentHash, - dest_custom_records: { 5482373484: preImage.toString('base64') }, - }, - this.httpLndConfig(), - ); + // Broadcast boundary: see sendPaymentByInvoice. + try { + return await this.http.post( + `${Config.blockchain.lightning.lnd.apiUrl}/channels/transactions`, + { + dest: Buffer.from(publicKey, 'hex').toString('base64'), + amt: Math.round(LightningHelper.btcToSat(amount)), + final_cltv_delta: 0, + payment_hash: paymentHash, + dest_custom_records: { 5482373484: preImage.toString('base64') }, + }, + this.httpLndConfig(), + ); + } catch (e) { + throw new TxBroadcastError(e instanceof Error ? e.message : String(e), { cause: e }); + } } // --- LnBits --- // diff --git a/src/shared/models/country/__tests__/enable-philippines-onboarding.migration.spec.ts b/src/shared/models/country/__tests__/enable-philippines-onboarding.migration.spec.ts new file mode 100644 index 0000000000..1bc1e73388 --- /dev/null +++ b/src/shared/models/country/__tests__/enable-philippines-onboarding.migration.spec.ts @@ -0,0 +1,118 @@ +import { IMemoryTable, newDb } from 'pg-mem'; + +type CountryRow = { + symbol: string; + dfxEnable: boolean; + dfxOrganizationEnable: boolean; + fatfEnable: boolean; + updated: Date; +}; + +let EnablePhilippinesOnboarding: new () => { + up(queryRunner: { query(sql: string): Promise }): Promise; + down(queryRunner: { query(sql: string): Promise }): Promise; +}; + +describe('EnablePhilippinesOnboarding migration', () => { + let db: ReturnType; + let query: jest.Mock, [string]>; + let migration: InstanceType; + + beforeAll(() => { + // The migration is intentionally a plain CommonJS module, matching TypeORM's runtime loader. + // eslint-disable-next-line @typescript-eslint/no-require-imports + EnablePhilippinesOnboarding = require('../../../../../migration/1784029705806-EnablePhilippinesOnboarding'); + }); + + beforeEach(() => { + db = newDb(); + db.public.none(` + CREATE TABLE "country" ( + "symbol" character varying(10) PRIMARY KEY, + "dfxEnable" boolean NOT NULL, + "dfxOrganizationEnable" boolean NOT NULL, + "fatfEnable" boolean NOT NULL, + "updated" TIMESTAMP NOT NULL + ) + `); + + query = jest.fn(async (sql: string) => db.public.none(sql)); + migration = new EnablePhilippinesOnboarding(); + }); + + const insertCountry = ( + symbol: string, + dfxEnable: boolean, + dfxOrganizationEnable = false, + fatfEnable = true, + ): void => { + getCountryTable().insert({ + symbol, + dfxEnable, + dfxOrganizationEnable, + fatfEnable, + updated: new Date('2024-01-01T00:00:00.000Z'), + }); + }; + + const getCountryTable = () => db.public.getTable('country') as IMemoryTable; + + const getCountry = (symbol: string): CountryRow => getCountryTable().find({ symbol })[0]; + + it('enables only individual DFX onboarding for PH and is idempotent', async () => { + insertCountry('PH', false); + insertCountry('CH', false, true, false); + + await migration.up({ query }); + + const enabled = getCountry('PH'); + expect(enabled).toMatchObject({ + symbol: 'PH', + dfxEnable: true, + dfxOrganizationEnable: false, + fatfEnable: true, + }); + expect(enabled.updated).not.toEqual(new Date('2024-01-01T00:00:00.000Z')); + expect(getCountry('CH')).toMatchObject({ + dfxEnable: false, + dfxOrganizationEnable: true, + fatfEnable: false, + updated: new Date('2024-01-01T00:00:00.000Z'), + }); + + await migration.up({ query }); + + expect(getCountry('PH')).toEqual(enabled); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('does not rewrite an already enabled PH row', async () => { + insertCountry('PH', true); + + await migration.up({ query }); + + expect(getCountry('PH')).toMatchObject({ + dfxEnable: true, + updated: new Date('2024-01-01T00:00:00.000Z'), + }); + }); + + it('is a safe no-op when the PH row does not exist yet', async () => { + insertCountry('CH', true, true, true); + + await expect(migration.up({ query })).resolves.toBeUndefined(); + + expect(getCountryTable().find()).toEqual([getCountry('CH')]); + }); + + it('does not silently reintroduce the restriction during rollback', async () => { + insertCountry('PH', false); + await migration.up({ query }); + query.mockClear(); + + await migration.down({ query }); + + expect(getCountry('PH').dfxEnable).toBe(true); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index 5961fd2982..1ce05e5052 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -71,6 +71,7 @@ export enum AmlError { BANK_TX_CUSTOMER_NAME_MISSING = 'BankTxCustomerNameMissing', FORCE_MANUAL_CHECK = 'ForceManualCheck', SCORECHAIN_HIGH_RISK = 'ScorechainHighRisk', + SCORECHAIN_UNAVAILABLE = 'ScorechainUnavailable', ASSET_INPUT_NOT_ALLOWED = 'AssetInputNotAllowed', REFERRAL_NO_TRADE_HISTORY = 'ReferralNoTradeHistory', } @@ -386,6 +387,16 @@ export const AmlErrorResult: { amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK, }, + // Screening yielded no usable verdict (provider 5xx/timeout, quota reached, misconfiguration): fail-closed + // to manual review, but NOT a Scorechain hit. Depending on the cause there is either no screening row and + // no compliance PDF at all (transport/quota error), or a persisted row whose score could not be evaluated + // (missing risk threshold) — never a scored hit. + // amlReason stays the generic MANUAL_CHECK (no tipping-off, same public story as any other manual hold). + [AmlError.SCORECHAIN_UNAVAILABLE]: { + type: AmlErrorType.CRUCIAL, + amlCheck: CheckStatus.PENDING, + amlReason: AmlReason.MANUAL_CHECK, + }, [AmlError.ASSET_INPUT_NOT_ALLOWED]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.FAIL, diff --git a/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts b/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts new file mode 100644 index 0000000000..a03dd7c0e0 --- /dev/null +++ b/src/subdomains/core/aml/enums/scorechain-outcome.enum.ts @@ -0,0 +1,16 @@ +import { AmlError } from 'src/subdomains/core/aml/enums/aml-error.enum'; + +// Outcome of the Scorechain on-chain screening gate, as consumed by the AML computation. +// PASS also covers "no signal" (Scorechain disabled, unconfigured, unsupported chain, no object id): +// the tx is then decided by the other AML mechanisms, exactly as before. +export enum ScorechainOutcome { + PASS = 'Pass', + HIGH_RISK = 'HighRisk', + UNAVAILABLE = 'Unavailable', +} + +export const ScorechainOutcomeError: { [o in ScorechainOutcome]: AmlError | null } = { + [ScorechainOutcome.PASS]: null, + [ScorechainOutcome.HIGH_RISK]: AmlError.SCORECHAIN_HIGH_RISK, + [ScorechainOutcome.UNAVAILABLE]: AmlError.SCORECHAIN_UNAVAILABLE, +}; diff --git a/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts b/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts index a1223e620e..cef5a3a055 100644 --- a/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts +++ b/src/subdomains/core/aml/services/__tests__/aml-helper.service.spec.ts @@ -40,6 +40,7 @@ import { TestUtil } from 'src/shared/utils/test.util'; import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { AmlRule } from 'src/subdomains/core/aml/enums/aml-rule.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { KycLevel, KycType } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; @@ -49,7 +50,7 @@ describe('AmlHelperService - Scorechain gate', () => { // Minimal entity: getAmlErrors is stubbed, so only the fields getAmlResult itself reads matter. const entity = { amlCheck: null, created: new Date(0) } as any; - const getAmlResult = (scorechainHighRisk: boolean) => + const getAmlResult = (scorechainOutcome: ScorechainOutcome) => AmlHelperService.getAmlResult( entity, null as any, // inputAsset @@ -68,27 +69,37 @@ describe('AmlHelperService - Scorechain gate', () => { undefined, // ipLogCountries undefined, // virtualIban undefined, // multiAccountBankNames - scorechainHighRisk, + scorechainOutcome, ); - it('forwards scorechainHighRisk to getAmlErrors as the last argument', () => { + it('forwards scorechainOutcome to getAmlErrors as the last argument', () => { const spy = jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([]); - getAmlResult(true); + getAmlResult(ScorechainOutcome.HIGH_RISK); - expect(spy.mock.calls[0].at(-1)).toBe(true); + expect(spy.mock.calls[0].at(-1)).toBe(ScorechainOutcome.HIGH_RISK); }); it('resolves a SCORECHAIN_HIGH_RISK error to a PENDING manual-review check (after the 10-min grace)', () => { jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([AmlError.SCORECHAIN_HIGH_RISK]); - const result = getAmlResult(true); + const result = getAmlResult(ScorechainOutcome.HIGH_RISK); expect(result.amlCheck).toBe(CheckStatus.PENDING); expect(result.amlReason).toBe(AmlReason.MANUAL_CHECK); expect(result.comment).toContain('ScorechainHighRisk'); }); + it('resolves a SCORECHAIN_UNAVAILABLE error to a PENDING manual-review check (after the 10-min grace)', () => { + jest.spyOn(AmlHelperService, 'getAmlErrors').mockReturnValue([AmlError.SCORECHAIN_UNAVAILABLE]); + + const result = getAmlResult(ScorechainOutcome.UNAVAILABLE); + + expect(result.amlCheck).toBe(CheckStatus.PENDING); + expect(result.amlReason).toBe(AmlReason.MANUAL_CHECK); + expect(result.comment).toContain('ScorechainUnavailable'); + }); + describe('getAmlErrors Scorechain branch (real call)', () => { beforeAll(async () => { await Test.createTestingModule({ providers: [TestUtil.provideConfig()] }).compile(); @@ -143,7 +154,7 @@ describe('AmlHelperService - Scorechain gate', () => { const inputAsset = createCustomAsset({ name: 'BTC', amlRuleFrom: AmlRule.DEFAULT, sellable: true }); const ibanCountry = { symbol: 'DE', fatfEnable: true, amlRule: AmlRule.DEFAULT } as any; - const collect = (scorechainHighRisk: boolean): AmlError[] => + const collect = (scorechainOutcome: ScorechainOutcome): AmlError[] => AmlHelperService.getAmlErrors( cleanEntity(), inputAsset, @@ -162,15 +173,21 @@ describe('AmlHelperService - Scorechain gate', () => { undefined, // virtualIban undefined, // multiAccountBankNames undefined, // recommender - scorechainHighRisk, + scorechainOutcome, ); it('adds SCORECHAIN_HIGH_RISK when the screening is high-risk', () => { - expect(collect(true)).toContain(AmlError.SCORECHAIN_HIGH_RISK); + expect(collect(ScorechainOutcome.HIGH_RISK)).toContain(AmlError.SCORECHAIN_HIGH_RISK); }); it('adds no Scorechain error when the screening is clean', () => { - expect(collect(false)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK); + expect(collect(ScorechainOutcome.PASS)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK); + expect(collect(ScorechainOutcome.PASS)).not.toContain(AmlError.SCORECHAIN_UNAVAILABLE); + }); + + it('adds SCORECHAIN_UNAVAILABLE (not SCORECHAIN_HIGH_RISK) when screening was unavailable', () => { + expect(collect(ScorechainOutcome.UNAVAILABLE)).toContain(AmlError.SCORECHAIN_UNAVAILABLE); + expect(collect(ScorechainOutcome.UNAVAILABLE)).not.toContain(AmlError.SCORECHAIN_HIGH_RISK); }); }); }); diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 58b5b2d2be..c5df834318 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -30,6 +30,7 @@ import { AmlError, AmlErrorResult, AmlErrorType, DelayResultError } from '../enu import { AmlReason, KycAmlReasons, RecheckAmlReasons } from '../enums/aml-reason.enum'; import { AmlRule, SpecialIpCountries } from '../enums/aml-rule.enum'; import { CheckStatus } from '../enums/check-status.enum'; +import { ScorechainOutcome, ScorechainOutcomeError } from '../enums/scorechain-outcome.enum'; export class AmlHelperService { static getAmlErrors( @@ -50,7 +51,7 @@ export class AmlHelperService { virtualIban?: VirtualIban, multiAccountBankNames?: string[], recommender?: UserData, - scorechainHighRisk = false, + scorechainOutcome: ScorechainOutcome = ScorechainOutcome.PASS, ): AmlError[] { const errors: AmlError[] = []; const nationality = entity.userData.nationality; @@ -62,8 +63,11 @@ export class AmlHelperService { return errors; // Scorechain on-chain screening (withdrawal target address / deposit tx); the async call runs in - // the AML orchestrator and is reduced to this boolean. Outcome layer only (CRUCIAL → manual review). - if (scorechainHighRisk) errors.push(AmlError.SCORECHAIN_HIGH_RISK); + // the AML orchestrator. HIGH_RISK is a real screening hit; UNAVAILABLE means screening was + // impossible (provider outage/timeout/quota/misconfiguration) — both are CRUCIAL → manual review, + // but they must stay distinguishable (see ScorechainOutcomeError / AmlErrorResult). + const scorechainError = ScorechainOutcomeError[scorechainOutcome]; + if (scorechainError) errors.push(scorechainError); if (isAsset(inputAsset) && inputAsset.name === 'REALU') errors.push(AmlError.ASSET_INPUT_NOT_ALLOWED); @@ -615,7 +619,7 @@ export class AmlHelperService { ipLogCountries?: string[], virtualIban?: VirtualIban, multiAccountBankNames?: string[], - scorechainHighRisk = false, + scorechainOutcome: ScorechainOutcome = ScorechainOutcome.PASS, ): { bankData?: BankData; amlCheck?: CheckStatus; @@ -642,7 +646,7 @@ export class AmlHelperService { virtualIban, multiAccountBankNames, recommender, - scorechainHighRisk, + scorechainOutcome, ).filter((e) => e); const comment = Array.from(new Set(amlErrors)).join(';'); diff --git a/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts b/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts index 58361b57ec..1903e56c90 100644 --- a/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts +++ b/src/subdomains/core/buy-crypto/process/entities/__tests__/buy-crypto.entity.spec.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { TestUtil } from 'src/shared/utils/test.util'; import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlHelperService } from 'src/subdomains/core/aml/services/aml-helper.service'; import { LiquidityManagementPipelineStatus } from 'src/subdomains/core/liquidity-management/enums'; import { Price, PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; @@ -601,7 +602,7 @@ describe('BuyCrypto', () => { describe('#amlCheckAndFillUp Scorechain gate', () => { afterEach(() => jest.restoreAllMocks()); - const run = (entity: BuyCrypto, screen?: () => Promise) => + const run = (entity: BuyCrypto, screen?: () => Promise) => entity.amlCheckAndFillUp( null as any, // inputAsset 0, // minVolume @@ -625,7 +626,7 @@ describe('BuyCrypto', () => { it('does not screen when the tx would not otherwise pass', async () => { jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.FAIL } as any); - const screen = jest.fn().mockResolvedValue(true); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK); await run(createDefaultBuyCrypto(), screen); @@ -638,21 +639,21 @@ describe('BuyCrypto', () => { .spyOn(AmlHelperService, 'getAmlResult') .mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any) .mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any); - const screen = jest.fn().mockResolvedValue(true); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK); const entity = createDefaultBuyCrypto(); await run(entity, screen); expect(screen).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(2); - expect(spy.mock.calls[0].at(-1)).toBe(false); // phase 1: scorechainHighRisk=false - expect(spy.mock.calls[1].at(-1)).toBe(true); // phase 2: scorechainHighRisk=true + expect(spy.mock.calls[0].at(-1)).toBe(ScorechainOutcome.PASS); // phase 1 + expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.HIGH_RISK); // phase 2 expect(entity.amlCheck).toBe(CheckStatus.PENDING); }); it('keeps PASS when the tx would pass and screening is clean (no phase 2)', async () => { jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.PASS } as any); - const screen = jest.fn().mockResolvedValue(false); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.PASS); const entity = createDefaultBuyCrypto(); await run(entity, screen); @@ -669,6 +670,22 @@ describe('BuyCrypto', () => { expect(AmlHelperService.getAmlResult).toHaveBeenCalledTimes(1); }); + + it('screens when the tx would otherwise pass and flips to PENDING when the provider is unavailable', async () => { + const spy = jest + .spyOn(AmlHelperService, 'getAmlResult') + .mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any) + .mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.UNAVAILABLE); + const entity = createDefaultBuyCrypto(); + + await run(entity, screen); + + expect(screen).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.UNAVAILABLE); + expect(entity.amlCheck).toBe(CheckStatus.PENDING); + }); }); describe('#resetAmlCheck()', () => { diff --git a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts index 483080e3bd..58c72a1181 100644 --- a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts +++ b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts @@ -39,6 +39,7 @@ import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricin import { Column, Entity, Index, JoinColumn, ManyToOne, OneToOne } from 'typeorm'; import { AmlReason } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; +import { ScorechainOutcome } from '../../../aml/enums/scorechain-outcome.enum'; import { Buy } from '../../routes/buy/buy.entity'; import { BuyCryptoBatch } from './buy-crypto-batch.entity'; import { BuyCryptoFee } from './buy-crypto-fees.entity'; @@ -682,9 +683,9 @@ export class BuyCrypto extends IEntity { ipLogCountries?: string[], virtualIban?: VirtualIban, multiAccountBankNames?: string[], - screenScorechain?: () => Promise, + screenScorechain?: () => Promise, ): Promise> { - const computeAmlResult = (scorechainHighRisk: boolean) => + const computeAmlResult = (scorechainOutcome: ScorechainOutcome) => AmlHelperService.getAmlResult( this, inputAsset, @@ -703,13 +704,16 @@ export class BuyCrypto extends IEntity { ipLogCountries, virtualIban, multiAccountBankNames, - scorechainHighRisk, + scorechainOutcome, ); - let amlResult = computeAmlResult(false); + let amlResult = computeAmlResult(ScorechainOutcome.PASS); + // Run the (billable) Scorechain screening only when the tx would otherwise PASS. - if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS && (await screenScorechain())) - amlResult = computeAmlResult(true); + if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS) { + const scorechainOutcome = await screenScorechain(); + if (scorechainOutcome !== ScorechainOutcome.PASS) amlResult = computeAmlResult(scorechainOutcome); + } const update: Partial = { ...amlResult, diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts index 3aad880471..b2b74efcd1 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto-preparation.service.spec.ts @@ -11,6 +11,7 @@ import * as processServiceModule from 'src/shared/services/process.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { ScorechainDocumentService } from 'src/subdomains/generic/kyc/services/scorechain-document.service'; @@ -119,7 +120,7 @@ describe('BuyCryptoPreparationService', () => { } describe('screenScorechain (Scorechain AML gate)', () => { - const call = (entity: any): Promise => (service as any).screenScorechain(entity); + const call = (entity: any): Promise => (service as any).screenScorechain(entity); let apiKeyBackup: string | undefined; @@ -151,7 +152,7 @@ describe('BuyCryptoPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenWithdrawalAddress').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(true); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.HIGH_RISK); expect(scorechainScreeningService.screenWithdrawalAddress).toHaveBeenCalledWith(Blockchain.ETHEREUM, '0xabc'); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -163,7 +164,7 @@ describe('BuyCryptoPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(false); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).toHaveBeenCalledWith(Blockchain.BITCOIN, 'txhash'); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -175,7 +176,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('addr'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -186,7 +187,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue(undefined); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -198,7 +199,7 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('0xabc'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); @@ -210,11 +211,11 @@ describe('BuyCryptoPreparationService', () => { }); jest.spyOn(entity, 'targetAddress', 'get').mockReturnValue('0xabc'); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenWithdrawalAddress).not.toHaveBeenCalled(); }); - it('fails closed to manual review (true) when the provider throws (outage / quota reached)', async () => { + it('fails closed to manual review (UNAVAILABLE) when the provider throws (outage / quota reached)', async () => { const entity = createCustomBuyCrypto({ cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); @@ -222,7 +223,19 @@ describe('BuyCryptoPreparationService', () => { .spyOn(scorechainScreeningService, 'screenDepositTransaction') .mockRejectedValue(new Error('scorechain unavailable')); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); + }); + + it('fails closed to UNAVAILABLE when isHighRisk throws (misconfigured risk threshold)', async () => { + const entity = createCustomBuyCrypto({ + cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, + }); + jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); + jest.spyOn(scorechainScreeningService, 'isHighRisk').mockImplementation(() => { + throw new Error('SCORECHAIN_RISK_THRESHOLD is not configured'); + }); + + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); }); it('stores a compliance report for a freshly-screened tx tied to a customer', async () => { diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index ec7fed3d85..b33053e2cc 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -13,6 +13,7 @@ import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AmountType, Util } from 'src/shared/utils/util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { BlockAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; @@ -70,17 +71,17 @@ export class BuyCryptoPreparationService { // does not cover yield no signal (the other AML mechanisms apply). isHighRisk is fail-closed for // deposits (invalid signature / no coverage / unsupported → high risk); a withdrawal to an address // with no coverage passes, since a fresh destination address has no data to assess. - private async screenScorechain(entity: BuyCrypto): Promise { + private async screenScorechain(entity: BuyCrypto): Promise { // Feature gate / kill-switch: when Scorechain is disabled or unconfigured (no API key), emit no // signal so the tx is decided by the other AML mechanisms. This is the deliberate off-state and // must never route an unscreened-because-off tx to manual review. - if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return false; + if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return ScorechainOutcome.PASS; const [blockchain, objectId, isDeposit] = entity.cryptoInput ? [entity.cryptoInput.asset.blockchain, entity.cryptoInput.inTxId, true] : [entity.outputAsset.blockchain, entity.targetAddress, false]; - if (!objectId || !toScorechainBlockchain(blockchain)) return false; + if (!objectId || !toScorechainBlockchain(blockchain)) return ScorechainOutcome.PASS; try { const screening = isDeposit @@ -92,13 +93,18 @@ export class BuyCryptoPreparationService { if (screening.isNewlyScreened && entity.userData) await this.scorechainDocumentService.createScreeningReport(entity.userData, screening); - return this.scorechainScreeningService.isHighRisk(screening); + return this.scorechainScreeningService.isHighRisk(screening) + ? ScorechainOutcome.HIGH_RISK + : ScorechainOutcome.PASS; } catch (e) { // Fail-closed to manual review: a provider/transport error or a reached monthly quota must not // throw out of the AML computation (which would silently stall settlement of every otherwise- - // passing tx on every cron run). Treat it as high risk → SCORECHAIN_HIGH_RISK → PENDING. - this.logger.error(`Scorechain screening failed for buy-crypto ${entity.id}, routing to manual review:`, e); - return true; + // passing tx on every cron run). + // Classify as UNAVAILABLE, never HIGH_RISK: no risk verdict could be established. A transport or quota + // error leaves no screening row and no compliance PDF at all; a missing risk threshold throws only + // after both have been persisted. In neither case may the tx be recorded as an actual Scorechain hit. + this.logger.error(`Scorechain screening unavailable for buy-crypto ${entity.id}, routing to manual review:`, e); + return ScorechainOutcome.UNAVAILABLE; } } diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts index 3fc0b09435..2b46f63b41 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts @@ -4,6 +4,7 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { TestUtil } from 'src/shared/utils/test.util'; import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlHelperService } from 'src/subdomains/core/aml/services/aml-helper.service'; import { createCustomFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; @@ -124,7 +125,7 @@ describe('BuyFiat entity', () => { }); afterEach(() => jest.restoreAllMocks()); - const run = (entity: any, screen?: () => Promise) => + const run = (entity: any, screen?: () => Promise) => entity.amlCheckAndFillUp( null, // inputAsset 0, // minVolume @@ -143,7 +144,7 @@ describe('BuyFiat entity', () => { it('does not screen when the tx would not otherwise pass', async () => { jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.FAIL } as any); - const screen = jest.fn().mockResolvedValue(true); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK); await run(createCustomBuyFiat({}), screen); @@ -156,21 +157,21 @@ describe('BuyFiat entity', () => { .spyOn(AmlHelperService, 'getAmlResult') .mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any) .mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any); - const screen = jest.fn().mockResolvedValue(true); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.HIGH_RISK); const entity = createCustomBuyFiat({}); await run(entity, screen); expect(screen).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(2); - expect(spy.mock.calls[0].at(-1)).toBe(false); // phase 1 - expect(spy.mock.calls[1].at(-1)).toBe(true); // phase 2 + expect(spy.mock.calls[0].at(-1)).toBe(ScorechainOutcome.PASS); // phase 1 + expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.HIGH_RISK); // phase 2 expect(entity.amlCheck).toBe(CheckStatus.PENDING); }); it('keeps PASS when the tx would pass and screening is clean (no phase 2)', async () => { jest.spyOn(AmlHelperService, 'getAmlResult').mockReturnValue({ amlCheck: CheckStatus.PASS } as any); - const screen = jest.fn().mockResolvedValue(false); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.PASS); const entity = createCustomBuyFiat({}); await run(entity, screen); @@ -179,6 +180,22 @@ describe('BuyFiat entity', () => { expect(AmlHelperService.getAmlResult).toHaveBeenCalledTimes(1); expect(entity.amlCheck).toBe(CheckStatus.PASS); }); + + it('screens when the tx would otherwise pass and flips to PENDING when the provider is unavailable', async () => { + const spy = jest + .spyOn(AmlHelperService, 'getAmlResult') + .mockReturnValueOnce({ amlCheck: CheckStatus.PASS } as any) + .mockReturnValueOnce({ amlCheck: CheckStatus.PENDING, amlReason: AmlReason.MANUAL_CHECK } as any); + const screen = jest.fn().mockResolvedValue(ScorechainOutcome.UNAVAILABLE); + const entity = createCustomBuyFiat({}); + + await run(entity, screen); + + expect(screen).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1].at(-1)).toBe(ScorechainOutcome.UNAVAILABLE); + expect(entity.amlCheck).toBe(CheckStatus.PENDING); + }); }); describe('#resetAmlCheck()', () => { diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts index a0c84bd838..bef50660e4 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts @@ -28,6 +28,7 @@ import { FiatOutput } from '../../../supporting/fiat-output/fiat-output.entity'; import { Transaction } from '../../../supporting/payment/entities/transaction.entity'; import { AmlReason } from '../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../aml/enums/check-status.enum'; +import { ScorechainOutcome } from '../../aml/enums/scorechain-outcome.enum'; import { AmlHelperService } from '../../aml/services/aml-helper.service'; import { PaymentLinkPayment } from '../../payment-link/entities/payment-link-payment.entity'; import { Sell } from '../route/sell.entity'; @@ -471,9 +472,9 @@ export class BuyFiat extends IEntity { ibanCountry: Country, refUser?: User, recommender?: UserData, - screenScorechain?: () => Promise, + screenScorechain?: () => Promise, ): Promise> { - const computeAmlResult = (scorechainHighRisk: boolean) => + const computeAmlResult = (scorechainOutcome: ScorechainOutcome) => AmlHelperService.getAmlResult( this, inputAsset, @@ -492,13 +493,16 @@ export class BuyFiat extends IEntity { undefined, undefined, undefined, - scorechainHighRisk, + scorechainOutcome, ); - let amlResult = computeAmlResult(false); + let amlResult = computeAmlResult(ScorechainOutcome.PASS); + // Run the (billable) Scorechain screening only when the tx would otherwise PASS. - if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS && (await screenScorechain())) - amlResult = computeAmlResult(true); + if (screenScorechain && amlResult.amlCheck === CheckStatus.PASS) { + const scorechainOutcome = await screenScorechain(); + if (scorechainOutcome !== ScorechainOutcome.PASS) amlResult = computeAmlResult(scorechainOutcome); + } const update: Partial = { ...amlResult, diff --git a/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts index c5cccdf047..656bc2334e 100644 --- a/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/services/__tests__/buy-fiat-preparation.service.spec.ts @@ -8,6 +8,7 @@ import * as processServiceModule from 'src/shared/services/process.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { CustodyOrderService } from 'src/subdomains/core/custody/services/custody-order.service'; @@ -106,7 +107,7 @@ describe('BuyFiatPreparationService', () => { } describe('screenScorechain (Scorechain AML gate)', () => { - const call = (entity: any): Promise => (service as any).screenScorechain(entity); + const call = (entity: any): Promise => (service as any).screenScorechain(entity); let apiKeyBackup: string | undefined; @@ -136,7 +137,7 @@ describe('BuyFiatPreparationService', () => { jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); jest.spyOn(scorechainScreeningService, 'isHighRisk').mockReturnValue(true); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.HIGH_RISK); expect(scorechainScreeningService.screenDepositTransaction).toHaveBeenCalledWith(Blockchain.BITCOIN, 'txhash'); }); @@ -145,7 +146,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.MONERO }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -154,7 +155,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: undefined } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -164,7 +165,7 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); @@ -174,11 +175,11 @@ describe('BuyFiatPreparationService', () => { cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); - await expect(call(entity)).resolves.toBe(false); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.PASS); expect(scorechainScreeningService.screenDepositTransaction).not.toHaveBeenCalled(); }); - it('fails closed to manual review (true) when the provider throws (outage / quota reached)', async () => { + it('fails closed to manual review (UNAVAILABLE) when the provider throws (outage / quota reached)', async () => { const entity = createCustomBuyFiat({ cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, }); @@ -186,7 +187,19 @@ describe('BuyFiatPreparationService', () => { .spyOn(scorechainScreeningService, 'screenDepositTransaction') .mockRejectedValue(new Error('scorechain unavailable')); - await expect(call(entity)).resolves.toBe(true); + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); + }); + + it('fails closed to UNAVAILABLE when isHighRisk throws (misconfigured risk threshold)', async () => { + const entity = createCustomBuyFiat({ + cryptoInput: { asset: { blockchain: Blockchain.BITCOIN }, inTxId: 'txhash' } as any, + }); + jest.spyOn(scorechainScreeningService, 'screenDepositTransaction').mockResolvedValue({} as any); + jest.spyOn(scorechainScreeningService, 'isHighRisk').mockImplementation(() => { + throw new Error('SCORECHAIN_RISK_THRESHOLD is not configured'); + }); + + await expect(call(entity)).resolves.toBe(ScorechainOutcome.UNAVAILABLE); }); it('stores a compliance report for a freshly-screened deposit tied to a customer', async () => { diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts index b8488e4d66..6b4dbaaea9 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts @@ -9,6 +9,7 @@ import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AmountType, Util } from 'src/shared/utils/util'; import { AmlSourceType } from 'src/subdomains/core/aml/entities/transaction-aml-check.entity'; import { BlockAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; +import { ScorechainOutcome } from 'src/subdomains/core/aml/enums/scorechain-outcome.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; import { TransactionAmlCheckService } from 'src/subdomains/core/aml/services/transaction-aml-check.service'; import { CustodyOrderStatus } from 'src/subdomains/core/custody/enums/custody'; @@ -64,15 +65,15 @@ export class BuyFiatPreparationService { // Scorechain on-chain screening for the sell/BuyFiat AML gate: screens the incoming crypto deposit // tx. Chains Scorechain does not cover yield no signal (the other AML mechanisms apply). isHighRisk // is fail-closed (invalid signature / no coverage / unsupported → high risk). - private async screenScorechain(entity: BuyFiat): Promise { + private async screenScorechain(entity: BuyFiat): Promise { // Feature gate / kill-switch: when Scorechain is disabled or unconfigured (no API key), emit no // signal so the tx is decided by the other AML mechanisms. This is the deliberate off-state and // must never route an unscreened-because-off tx to manual review. - if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return false; + if (DisabledProcess(Process.SCORECHAIN) || !Config.scorechain.apiKey) return ScorechainOutcome.PASS; const blockchain = entity.cryptoInput?.asset.blockchain; const txHash = entity.cryptoInput?.inTxId; - if (!txHash || !toScorechainBlockchain(blockchain)) return false; + if (!txHash || !toScorechainBlockchain(blockchain)) return ScorechainOutcome.PASS; try { const screening = await this.scorechainScreeningService.screenDepositTransaction(blockchain, txHash); @@ -82,13 +83,18 @@ export class BuyFiatPreparationService { if (screening.isNewlyScreened && entity.userData) await this.scorechainDocumentService.createScreeningReport(entity.userData, screening); - return this.scorechainScreeningService.isHighRisk(screening); + return this.scorechainScreeningService.isHighRisk(screening) + ? ScorechainOutcome.HIGH_RISK + : ScorechainOutcome.PASS; } catch (e) { // Fail-closed to manual review: a provider/transport error or a reached monthly quota must not // throw out of the AML computation (which would silently stall settlement of every otherwise- - // passing tx on every cron run). Treat it as high risk → SCORECHAIN_HIGH_RISK → PENDING. - this.logger.error(`Scorechain screening failed for buy-fiat ${entity.id}, routing to manual review:`, e); - return true; + // passing tx on every cron run). + // Classify as UNAVAILABLE, never HIGH_RISK: no risk verdict could be established. A transport or quota + // error leaves no screening row and no compliance PDF at all; a missing risk threshold throws only + // after both have been persisted. In neither case may the tx be recorded as an actual Scorechain hit. + this.logger.error(`Scorechain screening unavailable for buy-fiat ${entity.id}, routing to manual review:`, e); + return ScorechainOutcome.UNAVAILABLE; } } diff --git a/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts index 7d4911deb5..29268d6fea 100644 --- a/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts +++ b/src/subdomains/generic/kyc/services/__tests__/kyc.service.spec.ts @@ -1,8 +1,11 @@ import { createMock } from '@golevelup/ts-jest'; import { ForbiddenException } from '@nestjs/common'; +import { Configuration, ConfigService } from 'src/config/config'; import { BlobContent } from 'src/integration/infrastructure/azure-storage.service'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { createCustomCountry } from 'src/shared/models/country/__mocks__/country.entity.mock'; +import { Country } from 'src/shared/models/country/country.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock'; import { UserData } from '../../../user/models/user-data/user-data.entity'; @@ -15,6 +18,8 @@ import { KycFile } from '../../entities/kyc-file.entity'; import { KycStep } from '../../entities/kyc-step.entity'; import { ContentType } from '../../enums/content-type.enum'; import { KycStepName } from '../../enums/kyc-step-name.enum'; +import { ReviewStatus } from '../../enums/review-status.enum'; +import { KycStepRepository } from '../../repositories/kyc-step.repository'; import { KycDocumentService } from '../integration/kyc-document.service'; import { SumsubService } from '../integration/sum-sub.service'; import { KycFileService } from '../kyc-file.service'; @@ -382,3 +387,101 @@ describe('KycService downloadIdentDocuments', () => { ); }); }); + +// initiateStep auto-satisfies the NationalityData step from the account's already-known nationality +// (captured e.g. during RealUnit registration) so the user is not asked for it a second time - but +// only for the clean case (allowed, non-residence-permit nationality, no step errors), which is +// completed in-memory. A residence-permit or disallowed nationality, a merged/blocked account, a +// missing nationality, or a repeat attempt (preventDirectEvaluation) is deliberately left +// IN_PROGRESS, so the user goes through the normal nationality step that routes it to the correct +// internal/manual review and pulls in the residence-permit follow-up step. Never a blind complete. +describe('KycService initiateStep NATIONALITY_DATA auto-complete', () => { + let service: KycService; + let kycStepRepo: jest.Mocked; + + const userWithNationality = (nationality?: Country, overrides: Partial = {}): UserData => + createCustomUserData({ kycHash: 'hash', kycSteps: [], nationality, ...overrides }); + + const initiateNationalityStep = (user: UserData, preventDirectEvaluation = false): Promise => + (service as any).initiateStep(user, KycStepName.NATIONALITY_DATA, undefined, preventDirectEvaluation); + + // the NATIONALITY_DATA branch reads Config.kyc.residencePermitCountries; initialize the global + // Config (a module-level `let` that is undefined until a ConfigService is constructed) + beforeAll(() => { + new ConfigService(new Configuration()); + }); + + beforeEach(() => { + kycStepRepo = createMock(); + // save returns the (mutated) step, mirroring the real repo, so initiateStep's return is that step + (kycStepRepo.save as jest.Mock).mockImplementation(async (step) => step); + + // initiateStep's NATIONALITY_DATA path only touches the repo (via the final save) and the pure + // getNationalityErrors helper; avoid wiring all constructor deps + service = Object.create(KycService.prototype); + (service as any).kycStepRepo = kycStepRepo; + }); + + it('completes the step in-memory from an allowed nationality and stores it as the result', async () => { + const nationality = createCustomCountry({ symbol: 'DE', nationalityEnable: true }); + const user = userWithNationality(nationality); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.COMPLETED); + expect(step.getResult()).toMatchObject({ nationality: { id: nationality.id, symbol: 'DE' } }); + // only the final persist runs + expect(kycStepRepo.save).toHaveBeenCalledTimes(1); + }); + + it('leaves a residence-permit nationality IN_PROGRESS for the normal step', async () => { + const nationality = createCustomCountry({ symbol: 'RU', nationalityEnable: true }); + const user = userWithNationality(nationality); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); + + it('leaves a disallowed nationality IN_PROGRESS for the normal step', async () => { + const nationality = createCustomCountry({ symbol: 'DE', nationalityEnable: false }); + const user = userWithNationality(nationality); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); + + it('leaves the step IN_PROGRESS when the account has no known nationality', async () => { + const user = userWithNationality(undefined); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); + + it('leaves the step IN_PROGRESS on a repeat attempt (preventDirectEvaluation)', async () => { + const nationality = createCustomCountry({ symbol: 'DE', nationalityEnable: true }); + const user = userWithNationality(nationality); + + const step = await initiateNationalityStep(user, true); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); + + // fail-closed: a merged (or blocked) account yields step errors from getNationalityErrors, so even + // an allowed nationality must not be auto-completed + it('leaves an allowed nationality IN_PROGRESS when the account is merged (fail-closed)', async () => { + const nationality = createCustomCountry({ symbol: 'DE', nationalityEnable: true }); + const user = userWithNationality(nationality, { status: UserDataStatus.MERGED }); + + const step = await initiateNationalityStep(user); + + expect(step.status).toBe(ReviewStatus.IN_PROGRESS); + expect(step.result).toBeUndefined(); + }); +}); diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index a0741d6c97..7d9c68d81a 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -1346,6 +1346,31 @@ export class KycService { break; } + case KycStepName.NATIONALITY_DATA: + // Auto-satisfy the nationality step from the account's already-known + // nationality (e.g. captured during RealUnit registration), mirroring the + // CONTACT_DATA (mail) and PERSONAL_DATA (user fields) auto-completion + // above. Only the clean case is auto-completed (allowed, non-residence- + // permit nationality, no step errors); a residence-permit or disallowed + // nationality — or a merged/blocked account — is deliberately left + // IN_PROGRESS so the user goes through the normal nationality step, which + // routes it to the correct internal/manual review and pulls in the + // residence-permit follow-up step. Never a blind complete; without a known + // nationality or on a repeat attempt (preventDirectEvaluation) the step + // also stays IN_PROGRESS. + if ( + user.nationality && + !preventDirectEvaluation && + !Config.kyc.residencePermitCountries.includes(user.nationality.symbol) && + this.getNationalityErrors(kycStep, user.nationality).length === 0 + ) { + // Persist the same slim { id, symbol } shape the manual nationality + // step stores (updateNationalityStep), not the whole Country entity — + // avoids freezing mutable country config into an immutable step result. + kycStep.complete({ nationality: { id: user.nationality.id, symbol: user.nationality.symbol } }); + } + break; + case KycStepName.IDENT: if (kycStep.isSumsub) { kycStep.transactionId = SumsubService.transactionId(user, kycStep); @@ -1797,6 +1822,7 @@ export class KycService { users: true, kycSteps: { userData: true }, wallet: true, + nationality: true, }); } diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 0e1971db80..ca17e47d1f 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -114,7 +114,8 @@ export class UserData extends IEntity { @Column({ type: 'timestamp', nullable: true }) birthday?: Date; - @Column({ length: 256, nullable: true }) + // RealUnit stores a JSON list of tax residences here (not a single TIN); 1024 covers the DTO-bounded max. + @Column({ length: 1024, nullable: true }) tin?: string; // --- ORGANIZATION DATA --- // diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 2b4e6a24ef..9200a9a2c7 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -88,6 +88,11 @@ export class UserService { return this.userRepo.find({ where: { userData: { id: userDataId } }, relations }); } + async getUsersByUserDataIds(userDataIds: number[]): Promise { + if (!userDataIds.length) return []; + return this.userRepo.find({ where: { userData: { id: In(userDataIds) } }, relations: { userData: true } }); + } + async getUserByAddress(address: string, relations: FindOptionsRelations = {}): Promise { return this.userRepo.findOne({ where: { address }, relations }); } diff --git a/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts b/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts index 7d76a8ad9f..c72523e9fc 100644 --- a/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts +++ b/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts @@ -1,4 +1,7 @@ -import { PayoutOrderStatus } from '../payout-order.entity'; +import { getMetadataArgsStorage } from 'typeorm'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../payout-order.entity'; import { createCustomPayoutOrder } from '../__mocks__/payout-order.entity.mock'; describe('PayoutOrder', () => { @@ -108,6 +111,25 @@ describe('PayoutOrder', () => { expect(entity.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); }); + + it('returns this on a valid payoutTxId', () => { + const entity = createCustomPayoutOrder({ payoutTxId: undefined, status: undefined }); + + const result = entity.pendingPayout('PID_01'); + + expect(result).toBe(entity); + }); + + it('throws and leaves the order untouched when no payoutTxId is provided', () => { + const entity = createCustomPayoutOrder({ + payoutTxId: undefined, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + }); + + expect(() => entity.pendingPayout('')).toThrowError('No payoutTxId provided to PayoutOrder #pendingPayout(...)'); + expect(entity.payoutTxId).toBeUndefined(); + expect(entity.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); }); describe('#rollbackPayout(...)', () => { @@ -140,4 +162,130 @@ describe('PayoutOrder', () => { expect(entity.status).toBe(PayoutOrderStatus.COMPLETE); }); }); + + describe('#recordPreparationFee(...)', () => { + it('stores the preparation fee asset, amount and CHF amount and returns this', () => { + const entity = createCustomPayoutOrder({}); + const feeAsset = createCustomAsset({ name: 'BTC' }); + + const result = entity.recordPreparationFee(feeAsset, 0.0001, 4.2); + + expect(result).toBe(entity); + expect(entity.preparationFeeAsset).toBe(feeAsset); + expect(entity.preparationFeeAmount).toBe(0.0001); + expect(entity.preparationFeeAmountChf).toBe(4.2); + }); + }); + + describe('#recordPayoutFee(...)', () => { + it('stores the payout fee asset, amount and CHF amount and returns this', () => { + const entity = createCustomPayoutOrder({}); + const feeAsset = createCustomAsset({ name: 'BTC' }); + + const result = entity.recordPayoutFee(feeAsset, 0.0002, 8.4); + + expect(result).toBe(entity); + expect(entity.payoutFeeAsset).toBe(feeAsset); + expect(entity.payoutFeeAmount).toBe(0.0002); + expect(entity.payoutFeeAmountChf).toBe(8.4); + }); + }); + + describe('#recordPayoutFailure(...)', () => { + it('increments retryCount, truncates the error to 2048 chars, sets lastAttemptDate and returns this', () => { + const entity = createCustomPayoutOrder({ retryCount: 2 }); + const longMessage = 'X'.repeat(5000); + + const result = entity.recordPayoutFailure(longMessage); + + expect(result).toBe(entity); + expect(entity.retryCount).toBe(3); + expect(entity.lastError).toBe('X'.repeat(2048)); + expect(entity.lastError?.length).toBe(2048); + expect(entity.lastAttemptDate).toBeInstanceOf(Date); + }); + + it('starts retryCount at 1 when it was previously undefined', () => { + const entity = createCustomPayoutOrder({ retryCount: undefined }); + + entity.recordPayoutFailure('boom'); + + expect(entity.retryCount).toBe(1); + expect(entity.lastError).toBe('boom'); + }); + + it('leaves lastError undefined when no message is provided', () => { + const entity = createCustomPayoutOrder({ retryCount: 0 }); + + entity.recordPayoutFailure(undefined as unknown as string); + + expect(entity.retryCount).toBe(1); + expect(entity.lastError).toBeUndefined(); + expect(entity.lastAttemptDate).toBeInstanceOf(Date); + }); + }); + + describe('#resetPayoutRetry(...)', () => { + it('clears retryCount, lastError and lastAttemptDate and returns this', () => { + const entity = createCustomPayoutOrder({ retryCount: 5 }); + entity.lastError = 'previous error'; + entity.lastAttemptDate = new Date(); + + const result = entity.resetPayoutRetry(); + + expect(result).toBe(entity); + expect(entity.retryCount).toBe(0); + expect(entity.lastError).toBeNull(); + expect(entity.lastAttemptDate).toBeNull(); + }); + }); + + describe('#payoutFee', () => { + it('returns the payout fee asset and the rounded sum of payout and preparation amounts', () => { + const feeAsset = createCustomAsset({ name: 'BTC' }); + const entity = createCustomPayoutOrder({}); + entity.payoutFeeAsset = feeAsset; + entity.payoutFeeAmount = 0.00000002; + entity.preparationFeeAmount = 0.00000001; + + expect(entity.payoutFee).toEqual({ asset: feeAsset, amount: 0.00000003 }); + }); + }); + + describe('#feeAmountChf', () => { + it('sums the preparation and payout CHF fee amounts', () => { + const entity = createCustomPayoutOrder({}); + entity.preparationFeeAmountChf = 1.5; + entity.payoutFeeAmountChf = 2.25; + + expect(entity.feeAmountChf).toBe(3.75); + }); + }); + + describe('ORM metadata', () => { + it('maps asset, preparationFeeAsset and payoutFeeAsset as ManyToOne relations to Asset', () => { + const relations = getMetadataArgsStorage().relations.filter((r) => r.target === PayoutOrder); + const assetRelation = relations.find((r) => r.propertyName === 'asset'); + const preparationFeeRelation = relations.find((r) => r.propertyName === 'preparationFeeAsset'); + const payoutFeeRelation = relations.find((r) => r.propertyName === 'payoutFeeAsset'); + + expect((assetRelation!.type as () => unknown)()).toBe(Asset); + expect((preparationFeeRelation!.type as () => unknown)()).toBe(Asset); + expect((payoutFeeRelation!.type as () => unknown)()).toBe(Asset); + }); + + it('declares a unique composite index on (context, correlationId)', () => { + const index = getMetadataArgsStorage().indices.find( + (i) => i.target === PayoutOrder && typeof i.columns === 'function', + ); + + expect(index).toBeDefined(); + expect(index!.unique).toBe(true); + + const order = createCustomPayoutOrder({ context: PayoutOrderContext.BUY_CRYPTO, correlationId: 'CID_XYZ' }); + const columns = (index!.columns as (p: PayoutOrder) => unknown[])(order); + + expect(columns).toEqual([PayoutOrderContext.BUY_CRYPTO, 'CID_XYZ']); + }); + }); }); diff --git a/src/subdomains/supporting/payout/exceptions/__tests__/payout-broadcast.exception.spec.ts b/src/subdomains/supporting/payout/exceptions/__tests__/payout-broadcast.exception.spec.ts new file mode 100644 index 0000000000..0f376b1b9d --- /dev/null +++ b/src/subdomains/supporting/payout/exceptions/__tests__/payout-broadcast.exception.spec.ts @@ -0,0 +1,20 @@ +import { PayoutBroadcastException } from '../payout-broadcast.exception'; + +describe('PayoutBroadcastException', () => { + it('sets message and name', () => { + const exception = new PayoutBroadcastException('broadcast may be in-flight'); + + expect(exception.message).toBe('broadcast may be in-flight'); + expect(exception.name).toBe('PayoutBroadcastException'); + expect(exception).toBeInstanceOf(Error); + expect(exception.cause).toBeUndefined(); + }); + + it('carries the cause through to the Error options', () => { + const cause = new Error('underlying RPC error'); + + const exception = new PayoutBroadcastException('broadcast may be in-flight', { cause }); + + expect(exception.cause).toBe(cause); + }); +}); diff --git a/src/subdomains/supporting/payout/exceptions/payout-broadcast.exception.ts b/src/subdomains/supporting/payout/exceptions/payout-broadcast.exception.ts new file mode 100644 index 0000000000..1dca1989d9 --- /dev/null +++ b/src/subdomains/supporting/payout/exceptions/payout-broadcast.exception.ts @@ -0,0 +1,9 @@ +// Marks a payout failure at or after the actual on-chain broadcast call, i.e. the transaction may +// already be in-flight. Distinguishes this ambiguous case from a provable pre-broadcast failure +// (gas estimation, nonce fetch, gasPrice RPC) so the caller can decide fail-closed vs. auto-retry. +export class PayoutBroadcastException extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'PayoutBroadcastException'; + } +} diff --git a/src/subdomains/supporting/payout/repositories/__tests__/payout-order.repository.spec.ts b/src/subdomains/supporting/payout/repositories/__tests__/payout-order.repository.spec.ts new file mode 100644 index 0000000000..66ca7606cd --- /dev/null +++ b/src/subdomains/supporting/payout/repositories/__tests__/payout-order.repository.spec.ts @@ -0,0 +1,9 @@ +import { PayoutOrderRepository } from '../payout-order.repository'; + +describe('PayoutOrderRepository', () => { + it('constructs against the provided entity manager', () => { + const repo = new PayoutOrderRepository({} as any); + + expect(repo).toBeInstanceOf(PayoutOrderRepository); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-arkade.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-arkade.service.spec.ts new file mode 100644 index 0000000000..cc3451151c --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-arkade.service.spec.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutArkadeService: sendTransaction catches a + * TxBroadcastError from the (shared, non-payout-specific) ArkadeService/ArkadeClient and re-throws it + * as a PayoutBroadcastException, so the payout strategy can tell "wallet.sendBitcoin was reached" + * apart from a provable pre-broadcast failure (see ArkadeStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { ArkadeClient } from 'src/integration/blockchain/arkade/arkade-client'; +import { ArkadeService } from 'src/integration/blockchain/arkade/arkade.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutArkadeService } from '../payout-arkade.service'; + +describe('PayoutArkadeService', () => { + let arkadeService: ArkadeService; + let arkadeClient: ArkadeClient; + let service: PayoutArkadeService; + let sendTransactionSpy: jest.SpyInstance; + + beforeEach(() => { + arkadeService = mock(); + arkadeClient = mock(); + jest.spyOn(arkadeService, 'getDefaultClient').mockReturnValue(arkadeClient); + sendTransactionSpy = jest.spyOn(arkadeService, 'sendTransaction'); + + service = new PayoutArkadeService(arkadeService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendTransaction(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('sendBitcoin failed'); + sendTransactionSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendTransaction('ARK_ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('sendBitcoin failed'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTransactionSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendTransaction('ARK_ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the txid on success and forwards address/amount', async () => { + sendTransactionSpy.mockResolvedValue({ txid: 'TX_HASH_01', fee: 0 }); + + await expect(service.sendTransaction('ARK_ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendTransactionSpy).toHaveBeenCalledWith('ARK_ADDR_01', 1.5); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the shared service', async () => { + const isHealthySpy = jest.spyOn(arkadeService, 'isHealthy').mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(arkadeClient, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(arkadeService, 'getTxActualFee').mockResolvedValue(0.0001); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.0001]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + jest.spyOn(arkadeClient, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(arkadeService, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentFeeForTransaction(...)', () => { + it('returns the native fee for a coin asset', async () => { + const coin = createCustomAsset({ type: AssetType.COIN }); + const getNativeFeeSpy = jest.spyOn(arkadeService, 'getNativeFee').mockResolvedValue(0.00001); + + await expect(service.getCurrentFeeForTransaction(coin)).resolves.toBe(0.00001); + expect(getNativeFeeSpy).toHaveBeenCalledTimes(1); + }); + + it('throws for a non-coin asset without touching the native fee', () => { + const token = createDefaultAsset(); // AssetType.TOKEN + const getNativeFeeSpy = jest.spyOn(arkadeService, 'getNativeFee'); + + expect(() => service.getCurrentFeeForTransaction(token)).toThrow('Method not implemented'); + expect(getNativeFeeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin-testnet4.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin-testnet4.service.spec.ts new file mode 100644 index 0000000000..61982ff7fa --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin-testnet4.service.spec.ts @@ -0,0 +1,192 @@ +/** + * Unit Tests for PayoutBitcoinTestnet4Service + * + * Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + * BitcoinTestnet4Client means the on-chain send was reached (tx may be in-flight) and must + * surface as PayoutBroadcastException so BitcoinBasedStrategy#send keeps the order + * PAYOUT_DESIGNATED (fail-closed) instead of rolling back and risking a double-spend on retry. + */ + +import { Config, ConfigService } from 'src/config/config'; +import { BitcoinTestnet4Client } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4-client'; +import { BitcoinTestnet4Service } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutGroup } from '../base/payout-bitcoin-based.service'; +import { PayoutBitcoinTestnet4Service } from '../payout-bitcoin-testnet4.service'; + +describe('PayoutBitcoinTestnet4Service', () => { + let service: PayoutBitcoinTestnet4Service; + let mockClient: jest.Mocked; + let sendManySpy: jest.Mock; + + beforeAll(() => { + // PayoutBitcoinTestnet4Service#getCurrentFeeRate reads the module-level `Config` singleton + // directly (only populated by the real app bootstrap), so a plain unit test must set it. + new ConfigService(); + }); + + beforeEach(() => { + sendManySpy = jest.fn().mockResolvedValue('TX_HASH_01'); + + mockClient = { + sendMany: sendManySpy, + getInfo: jest.fn(), + getTx: jest.fn(), + estimateSmartFee: jest.fn().mockResolvedValue(5), + } as unknown as jest.Mocked; + + const mockBitcoinTestnet4Service = { + getDefaultClient: jest.fn().mockReturnValue(mockClient), + } as unknown as jest.Mocked; + + service = new PayoutBitcoinTestnet4Service(mockBitcoinTestnet4Service); + }); + + describe('sendUtxoToMany()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + + const result = await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + + expect(result).toBe('TX_HASH_01'); + }); + + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const cause = new TxBroadcastError('Bitcoin RPC send failed: timeout'); + sendManySpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Bitcoin RPC send failed: timeout'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const plainError = new Error('unrelated client error'); + sendManySpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('isHealthy()', () => { + it('returns true when the node reports info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce({ blocks: 1000 }); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(mockClient.getInfo).toHaveBeenCalledTimes(1); + }); + + it('returns false when the node reports no info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce(null); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + + it('returns false (never throws) when the info call rejects', async () => { + (mockClient.getInfo as jest.Mock).mockRejectedValueOnce(new Error('node unreachable')); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + }); + + describe('getPayoutCompletionData()', () => { + it('returns [true, negated fee] once the tx is found', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + confirmations: 3, + time: 0, + amount: 0.5, + fee: -0.0002, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.0002]); + expect(mockClient.getTx).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('defaults the fee to 0 for a found tx that carries no fee field', async () => { + mockClient.getTx.mockResolvedValueOnce({ txid: 'TX_HASH_01', confirmations: 3, time: 0, amount: 0.5 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBe(true); + expect(result[1]).toBe(-0); // -(undefined ?? 0) + }); + + it('returns [false, 0] while the tx is not yet in the wallet', async () => { + mockClient.getTx.mockResolvedValueOnce(null); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([false, 0]); + }); + }); + + describe('getCurrentFeeRate()', () => { + it('applies the min-tx-amount multiplier (1.5) to the estimated smart fee', async () => { + mockClient.estimateSmartFee.mockResolvedValueOnce(5); + + await expect(service.getCurrentFeeRate()).resolves.toBe(7.5); + expect(mockClient.estimateSmartFee).toHaveBeenCalledWith(1); + }); + + it('falls back to the minimum rate of 1 when the smart-fee estimate is null', async () => { + mockClient.estimateSmartFee.mockResolvedValueOnce(null); + + await expect(service.getCurrentFeeRate()).resolves.toBe(1.5); + }); + + it('applies no multiplier when the min-tx-amount guard is disabled', async () => { + const originalMinTxAmount = Config.blockchain.bitcoinTestnet4.minTxAmount; + Config.blockchain.bitcoinTestnet4.minTxAmount = 0; + + try { + mockClient.estimateSmartFee.mockResolvedValueOnce(5); + + await expect(service.getCurrentFeeRate()).resolves.toBe(5); + } finally { + Config.blockchain.bitcoinTestnet4.minTxAmount = originalMinTxAmount; + } + }); + }); + + describe('with an unavailable default client', () => { + let serviceWithoutClient: PayoutBitcoinTestnet4Service; + + beforeEach(() => { + const mockServiceWithoutClient = { + getDefaultClient: jest.fn().mockReturnValue(undefined), + } as unknown as jest.Mocked; + + serviceWithoutClient = new PayoutBitcoinTestnet4Service(mockServiceWithoutClient); + }); + + it('isHealthy() returns false instead of throwing on the optional client', async () => { + await expect(serviceWithoutClient.isHealthy()).resolves.toBe(false); + }); + + it('getCurrentFeeRate() falls back to the minimum rate when the client is missing', async () => { + await expect(serviceWithoutClient.getCurrentFeeRate()).resolves.toBe(1.5); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin.service.spec.ts index d39fab7137..fa4fa4515e 100644 --- a/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin.service.spec.ts +++ b/src/subdomains/supporting/payout/services/__tests__/payout-bitcoin.service.spec.ts @@ -9,8 +9,10 @@ import { BitcoinClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; import { BitcoinService } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { PayoutOrderContext } from '../../entities/payout-order.entity'; import { InvalidPayoutAmountException } from '../../exceptions/invalid-payout-amount.exception'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; import { PayoutGroup } from '../base/payout-bitcoin-based.service'; import { PayoutBitcoinService } from '../payout-bitcoin.service'; @@ -134,5 +136,127 @@ describe('PayoutBitcoinService', () => { expect(result).toBe('TX_HASH_01'); }); + + // Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + // client means the on-chain send was reached (tx may be in-flight) and must surface as + // PayoutBroadcastException so BitcoinBasedStrategy#send keeps the order PAYOUT_DESIGNATED + // (fail-closed) instead of rolling back and risking a double-spend on retry. + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + mockFeeService.getSendFeeRate.mockResolvedValueOnce(5); + const cause = new TxBroadcastError('Bitcoin RPC send failed: timeout'); + sendManySpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Bitcoin RPC send failed: timeout'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + mockFeeService.getSendFeeRate.mockResolvedValueOnce(5); + const plainError = new Error('unrelated client error'); + sendManySpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('should propagate a pre-broadcast fee-rate timeout unchanged and never reach sendMany', async () => { + // Fee estimation happens before sendMany is even called - a timeout here is provably + // pre-broadcast and must self-heal (rollback), not fail-closed like a broadcast timeout. + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const timeoutError = new Error('RPC timeout during fee estimation'); + mockFeeService.getSendFeeRate.mockRejectedValueOnce(timeoutError); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(timeoutError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + expect(sendManySpy).not.toHaveBeenCalled(); + }); + }); + + describe('isHealthy()', () => { + it('returns true when the node reports info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce({ blocks: 800000 }); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(mockClient.getInfo).toHaveBeenCalledTimes(1); + }); + + it('returns false when the node reports no info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce(null); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + + it('returns false (never throws) when the info call rejects', async () => { + (mockClient.getInfo as jest.Mock).mockRejectedValueOnce(new Error('node unreachable')); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + }); + + describe('getPayoutCompletionData()', () => { + it('returns [true, negated fee] once the tx is found (Bitcoin Core reports outgoing fees as negative)', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + confirmations: 3, + time: 0, + amount: 0.5, + fee: -0.0002, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.0002]); + expect(mockClient.getTx).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('defaults the fee to 0 for a found tx that carries no fee field', async () => { + mockClient.getTx.mockResolvedValueOnce({ txid: 'TX_HASH_01', confirmations: 3, time: 0, amount: 0.5 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBe(true); + expect(result[1]).toBe(-0); // -(undefined ?? 0) + }); + + it('returns [false, 0] while the tx is not yet in the wallet', async () => { + mockClient.getTx.mockResolvedValueOnce(null); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([false, 0]); + }); + }); + + describe('getCurrentFeeRate()', () => { + it('delegates to the fee service', async () => { + mockFeeService.getSendFeeRate.mockResolvedValueOnce(7); + + await expect(service.getCurrentFeeRate()).resolves.toBe(7); + expect(mockFeeService.getSendFeeRate).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-cardano.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-cardano.service.spec.ts new file mode 100644 index 0000000000..7613fd16bd --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-cardano.service.spec.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutCardanoService: sendNativeCoin/sendToken + * catch a TxBroadcastError from the (shared, non-payout-specific) CardanoService/CardanoClient and + * re-throw it as a PayoutBroadcastException, so the payout strategy can tell "the on-chain submit + * call was reached" apart from a provable pre-broadcast failure (see CardanoStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { CardanoService } from 'src/integration/blockchain/cardano/services/cardano.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutCardanoService } from '../payout-cardano.service'; + +describe('PayoutCardanoService', () => { + let cardanoService: CardanoService; + let service: PayoutCardanoService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + cardanoService = mock(); + sendNativeCoinFromDexSpy = jest.spyOn(cardanoService, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(cardanoService, 'sendTokenFromDex'); + + service = new PayoutCardanoService(cardanoService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('BlockFrost rejected the tx'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('BlockFrost rejected the tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('BlockFrost rejected the token tx'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('BlockFrost rejected the token tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] and reads the actual fee once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(cardanoService, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(cardanoService, 'getTxActualFee').mockResolvedValue(0.17); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.17]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + const isTxCompleteSpy = jest.spyOn(cardanoService, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(cardanoService, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentGasForCoinTransaction()', () => { + it('delegates to the shared service', async () => { + const gasSpy = jest.spyOn(cardanoService, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(1.23); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(1.23); + expect(gasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + const asset = createDefaultAsset(); + + it('delegates to the shared service, forwarding the token', async () => { + const gasSpy = jest.spyOn(cardanoService, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(2.34); + + await expect(service.getCurrentGasForTokenTransaction(asset)).resolves.toBe(2.34); + expect(gasSpy).toHaveBeenCalledWith(asset); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-evm-chains.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-evm-chains.service.spec.ts new file mode 100644 index 0000000000..f01a10061a --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-evm-chains.service.spec.ts @@ -0,0 +1,168 @@ +/** + * Each PayoutXxxService is a thin, chain-specific binding over the shared PayoutEvmService: its + * constructor wires the chain's EvmService#getDefaultClient() into the inherited send/gas/nonce/expiry + * logic (already fully covered behaviourally by payout-evm.service.spec.ts). PayoutBaseService, + * PayoutGnosisService and PayoutOptimismService additionally keep their own concretely-typed client + * reference and override the two gas getters - this suite covers exactly those two extra branches. + * + * Two shared, table-driven suites (real per-chain call sites, not a silent loop - mirrors the + * runDesignateBeforeBroadcastSuite pattern in payout-designate-before-broadcast.strategy.spec.ts) + * cover all nine chain services: the six "plain" ones that only inherit PayoutEvmService's gas + * getters, and the three "override" ones that shadow them with their own client. + */ + +import { mock } from 'jest-mock-extended'; +import { ArbitrumService } from 'src/integration/blockchain/arbitrum/arbitrum.service'; +import { BaseClient } from 'src/integration/blockchain/base/base-client'; +import { BaseService } from 'src/integration/blockchain/base/base.service'; +import { BscService } from 'src/integration/blockchain/bsc/bsc.service'; +import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; +import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; +import { GnosisClient } from 'src/integration/blockchain/gnosis/gnosis-client'; +import { GnosisService } from 'src/integration/blockchain/gnosis/gnosis.service'; +import { OptimismClient } from 'src/integration/blockchain/optimism/optimism-client'; +import { OptimismService } from 'src/integration/blockchain/optimism/optimism.service'; +import { PolygonService } from 'src/integration/blockchain/polygon/polygon.service'; +import { SepoliaService } from 'src/integration/blockchain/sepolia/sepolia.service'; +import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutArbitrumService } from '../payout-arbitrum.service'; +import { PayoutBaseService } from '../payout-base.service'; +import { PayoutBscService } from '../payout-bsc.service'; +import { PayoutCitreaService } from '../payout-citrea.service'; +import { PayoutEthereumService } from '../payout-ethereum.service'; +import { PayoutEvmService } from '../payout-evm.service'; +import { PayoutGnosisService } from '../payout-gnosis.service'; +import { PayoutOptimismService } from '../payout-optimism.service'; +import { PayoutPolygonService } from '../payout-polygon.service'; +import { PayoutSepoliaService } from '../payout-sepolia.service'; + +interface PlainEvmChainVariant { + name: string; + createUnderlying: () => any; + createPayoutService: (underlying: any) => PayoutEvmService; +} + +function runPlainEvmChainServiceSuite(variant: PlainEvmChainVariant): void { + describe(variant.name, () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('wires the chain EvmService default client into the constructor, feeding the inherited gas getter', async () => { + const underlying = variant.createUnderlying(); + const client = mock(); + jest.spyOn(underlying, 'getDefaultClient').mockReturnValue(client as any); + jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(999); + + const service = variant.createPayoutService(underlying); + + expect(underlying.getDefaultClient).toHaveBeenCalledTimes(1); + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(999); + expect(client.getCurrentGasCostForCoinTransaction).toHaveBeenCalledTimes(1); + }); + }); +} + +runPlainEvmChainServiceSuite({ + name: 'PayoutArbitrumService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutArbitrumService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutBscService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutBscService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutCitreaService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutCitreaService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutEthereumService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutEthereumService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutPolygonService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutPolygonService(underlying), +}); + +runPlainEvmChainServiceSuite({ + name: 'PayoutSepoliaService', + createUnderlying: () => mock(), + createPayoutService: (underlying) => new PayoutSepoliaService(underlying), +}); + +interface OverrideEvmChainVariant { + name: string; + createUnderlying: () => any; + createClient: () => any; + createPayoutService: (underlying: any) => PayoutBaseService | PayoutGnosisService | PayoutOptimismService; +} + +function runOverrideEvmChainServiceSuite(variant: OverrideEvmChainVariant): void { + describe(variant.name, () => { + let underlying: any; + let client: any; + let service: PayoutBaseService | PayoutGnosisService | PayoutOptimismService; + + beforeEach(() => { + underlying = variant.createUnderlying(); + client = variant.createClient(); + jest.spyOn(underlying, 'getDefaultClient').mockReturnValue(client); + + service = variant.createPayoutService(underlying); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('wires the chain EvmService default client into the constructor', () => { + expect(underlying.getDefaultClient).toHaveBeenCalled(); + }); + + it('getCurrentGasForCoinTransaction(...) delegates to the client', async () => { + jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(333); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(333); + expect(client.getCurrentGasCostForCoinTransaction).toHaveBeenCalledTimes(1); + }); + + it('getCurrentGasForTokenTransaction(...) delegates to the client, forwarding the token', async () => { + const token = createDefaultAsset(); + jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(444); + + await expect(service.getCurrentGasForTokenTransaction(token)).resolves.toBe(444); + expect(client.getCurrentGasCostForTokenTransaction).toHaveBeenCalledWith(token); + }); + }); +} + +runOverrideEvmChainServiceSuite({ + name: 'PayoutBaseService', + createUnderlying: () => mock(), + createClient: () => mock(), + createPayoutService: (underlying) => new PayoutBaseService(underlying), +}); + +runOverrideEvmChainServiceSuite({ + name: 'PayoutGnosisService', + createUnderlying: () => mock(), + createClient: () => mock(), + createPayoutService: (underlying) => new PayoutGnosisService(underlying), +}); + +runOverrideEvmChainServiceSuite({ + name: 'PayoutOptimismService', + createUnderlying: () => mock(), + createClient: () => mock(), + createPayoutService: (underlying) => new PayoutOptimismService(underlying), +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-evm.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-evm.service.spec.ts new file mode 100644 index 0000000000..416ecc2a9a --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-evm.service.spec.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutEvmService: sendNativeCoin/sendToken + * catch a TxBroadcastError from the (shared, non-payout-specific) EvmClient and re-throw it as a + * PayoutBroadcastException, so the payout strategy can tell "the on-chain send call was reached" + * apart from a provable pre-broadcast failure (see EvmStrategy#doPayout). Anything else must + * propagate unchanged. + */ + +import { BigNumber } from 'ethers'; +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; +import { EvmService } from 'src/integration/blockchain/shared/evm/evm.service'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutEvmService } from '../payout-evm.service'; + +// PayoutEvmService is abstract - it has no chain-specific behaviour of its own, so a bare +// subclass is enough to exercise it. +class PayoutEvmServiceWrapper extends PayoutEvmService {} + +describe('PayoutEvmService', () => { + let client: EvmClient; + let service: PayoutEvmService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + client = mock(); + const evmService = mock(); + jest.spyOn(evmService, 'getDefaultClient').mockReturnValue(client); + sendNativeCoinFromDexSpy = jest.spyOn(client, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(client, 'sendTokenFromDex'); + + service = new PayoutEvmServiceWrapper(evmService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('rpc rejected the tx'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('rpc rejected the tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount/nonce', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5, 7)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5, 7); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('rpc rejected the token tx'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('rpc rejected the token tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount/nonce', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5, 9)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5, 9); + }); + }); + + // Happy-path coverage for the read-side of the EVM payout lifecycle: completion polling, gas + // estimation, nonce lookup and expiry detection. The broadcast-boundary (sendNativeCoin/sendToken) + // is covered above; this is the "everything went fine" half of the same service. + describe('getPayoutCompletionData(...)', () => { + it('returns pending when there is no receipt yet (tx not yet mined)', async () => { + jest.spyOn(client, 'getTxReceipt').mockResolvedValue(null as any); + + const result = await service.getPayoutCompletionData('TX_NO_RECEIPT'); + + expect(client.getTxReceipt).toHaveBeenCalledWith('TX_NO_RECEIPT'); + expect(result).toEqual({ state: 'pending' }); + }); + + it('returns pending when the receipt has zero confirmations (reorg-vulnerable)', async () => { + jest.spyOn(client, 'getTxReceipt').mockResolvedValue({ confirmations: 0, status: 1 } as any); + + const result = await service.getPayoutCompletionData('TX_ZERO_CONF'); + + expect(result).toEqual({ state: 'pending' }); + }); + + it('returns complete with the actual fee when the receipt is confirmed and successful (status 1)', async () => { + jest.spyOn(client, 'getTxReceipt').mockResolvedValue({ confirmations: 3, status: 1 } as any); + jest.spyOn(client, 'getTxActualFee').mockResolvedValue(0.00042); + + const result = await service.getPayoutCompletionData('TX_OK'); + + expect(client.getTxActualFee).toHaveBeenCalledWith('TX_OK'); + expect(result).toEqual({ state: 'complete', fee: 0.00042 }); + }); + + it('returns failed + isOutOfGas=true when the tx reverted and consumed exactly the gas limit', async () => { + jest + .spyOn(client, 'getTxReceipt') + .mockResolvedValue({ confirmations: 5, status: 0, gasUsed: BigNumber.from(21000) } as any); + jest.spyOn(client, 'getTx').mockResolvedValue({ gasLimit: BigNumber.from(21000) } as any); + + const result = await service.getPayoutCompletionData('TX_OOG'); + + expect(client.getTx).toHaveBeenCalledWith('TX_OOG'); + expect(result).toEqual({ state: 'failed', isOutOfGas: true }); + }); + + it('returns failed + isOutOfGas=false when the tx reverted without exhausting the gas limit', async () => { + jest + .spyOn(client, 'getTxReceipt') + .mockResolvedValue({ confirmations: 5, status: 0, gasUsed: BigNumber.from(15000) } as any); + jest.spyOn(client, 'getTx').mockResolvedValue({ gasLimit: BigNumber.from(21000) } as any); + + const result = await service.getPayoutCompletionData('TX_REVERT'); + + expect(result).toEqual({ state: 'failed', isOutOfGas: false }); + }); + + it('returns failed + isOutOfGas=false when the failed tx can no longer be found (tx is null)', async () => { + jest + .spyOn(client, 'getTxReceipt') + .mockResolvedValue({ confirmations: 5, status: 0, gasUsed: BigNumber.from(15000) } as any); + jest.spyOn(client, 'getTx').mockResolvedValue(null as any); + + const result = await service.getPayoutCompletionData('TX_GONE'); + + expect(result).toEqual({ state: 'failed', isOutOfGas: false }); + }); + }); + + describe('getCurrentGasForCoinTransaction(...)', () => { + it('delegates to the client', async () => { + jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(123); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(123); + expect(client.getCurrentGasCostForCoinTransaction).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + it('delegates to the client, forwarding the token asset', async () => { + const token = createDefaultAsset(); + jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(456); + + await expect(service.getCurrentGasForTokenTransaction(token)).resolves.toBe(456); + expect(client.getCurrentGasCostForTokenTransaction).toHaveBeenCalledWith(token); + }); + }); + + describe('getTxNonce(...)', () => { + it('delegates to the client', async () => { + jest.spyOn(client, 'getTxNonce').mockResolvedValue(7); + + await expect(service.getTxNonce('TX_N')).resolves.toBe(7); + expect(client.getTxNonce).toHaveBeenCalledWith('TX_N'); + }); + }); + + describe('isTxExpired(...)', () => { + it('returns false without checking the tx when the RPC is stale (not synced, age >= 300s)', async () => { + jest.spyOn(client, 'getLatestBlockTimestamp').mockResolvedValue(Date.now() / 1000 - 1000); + const getTxSpy = jest.spyOn(client, 'getTx'); + + await expect(service.isTxExpired('TX_STALE_RPC')).resolves.toBe(false); + + expect(getTxSpy).not.toHaveBeenCalled(); + }); + + it('returns true when the RPC is synced and the tx can no longer be found (dropped from mempool)', async () => { + jest.spyOn(client, 'getLatestBlockTimestamp').mockResolvedValue(Date.now() / 1000 - 5); + jest.spyOn(client, 'getTx').mockResolvedValue(null as any); + + await expect(service.isTxExpired('TX_DROPPED')).resolves.toBe(true); + + expect(client.getTx).toHaveBeenCalledWith('TX_DROPPED'); + }); + + it('returns false when the RPC is synced and the tx is still found (not expired)', async () => { + jest.spyOn(client, 'getLatestBlockTimestamp').mockResolvedValue(Date.now() / 1000 - 5); + jest.spyOn(client, 'getTx').mockResolvedValue({ hash: 'TX_STILL_THERE' } as any); + + await expect(service.isTxExpired('TX_STILL_THERE')).resolves.toBe(false); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts new file mode 100644 index 0000000000..291bf0ff6d --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts @@ -0,0 +1,228 @@ +/** + * Unit Tests for PayoutFiroService + * + * Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + * FiroClient means the on-chain send was reached (tx may be in-flight) and must surface as + * PayoutBroadcastException so BitcoinBasedStrategy#send keeps the order PAYOUT_DESIGNATED + * (fail-closed) instead of rolling back and risking a double-spend on retry. + */ + +import { FiroClient } from 'src/integration/blockchain/firo/firo-client'; +import { FiroFeeService } from 'src/integration/blockchain/firo/services/firo-fee.service'; +import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutGroup } from '../base/payout-bitcoin-based.service'; +import { PayoutFiroService } from '../payout-firo.service'; + +describe('PayoutFiroService', () => { + let service: PayoutFiroService; + let mockClient: jest.Mocked; + let mockFeeService: jest.Mocked; + let sendManySpy: jest.Mock; + let mintSparkSpy: jest.Mock; + + beforeEach(() => { + sendManySpy = jest.fn().mockResolvedValue('TX_HASH_01'); + mintSparkSpy = jest.fn().mockResolvedValue('MINT_TX_HASH_01'); + + mockClient = { + sendMany: sendManySpy, + mintSpark: mintSparkSpy, + getInfo: jest.fn(), + getTx: jest.fn(), + } as unknown as jest.Mocked; + + const mockFiroService = { + getDefaultClient: jest.fn().mockReturnValue(mockClient), + } as unknown as jest.Mocked; + + mockFeeService = { + getSendFeeRate: jest.fn().mockResolvedValue(10), + } as unknown as jest.Mocked; + + service = new PayoutFiroService(mockFiroService, mockFeeService); + }); + + describe('sendUtxoToMany()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + + const result = await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + + expect(result).toBe('TX_HASH_01'); + }); + + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const cause = new TxBroadcastError('Firo RPC sendrawtransaction failed'); + sendManySpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Firo RPC sendrawtransaction failed'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 0.5 }]; + const plainError = new Error('No non-deposit addresses with UTXOs available'); + sendManySpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendUtxoToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('mintSpark()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'sparkAddr1', amount: 0.5 }]; + + const result = await service.mintSpark(payout); + + expect(result).toBe('MINT_TX_HASH_01'); + }); + + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'sparkAddr1', amount: 0.5 }]; + const cause = new TxBroadcastError('Firo RPC mintspark failed'); + mintSparkSpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.mintSpark(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Firo RPC mintspark failed'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'sparkAddr1', amount: 0.5 }]; + const plainError = new Error('mintspark returned no transaction IDs'); + mintSparkSpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.mintSpark(payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('isHealthy()', () => { + it('returns true when the node reports info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce({ blocks: 500000 }); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(mockClient.getInfo).toHaveBeenCalledTimes(1); + }); + + it('returns false when the node reports no info', async () => { + (mockClient.getInfo as jest.Mock).mockResolvedValueOnce(null); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + + it('returns false (never throws) when the info call rejects', async () => { + (mockClient.getInfo as jest.Mock).mockRejectedValueOnce(new Error('node unreachable')); + + await expect(service.isHealthy()).resolves.toBe(false); + }); + }); + + describe('getPayoutCompletionData()', () => { + it('returns [true, negated fee] once the tx is mined (has blockhash and confirmations)', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + blockhash: 'BLOCK_HASH_01', + confirmations: 6, + time: 0, + amount: 0.5, + fee: -0.001, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.001]); + expect(mockClient.getTx).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('defaults the fee to 0 for a mined tx that carries no fee field', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + blockhash: 'BLOCK_HASH_01', + confirmations: 6, + time: 0, + amount: 0.5, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBe(true); + expect(result[1]).toBe(-0); // -(undefined ?? 0) + }); + + it('treats a tx with zero confirmations as not complete', async () => { + mockClient.getTx.mockResolvedValueOnce({ + txid: 'TX_HASH_01', + blockhash: 'BLOCK_HASH_01', + confirmations: 0, + time: 0, + amount: 0.5, + fee: -0.001, + }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBe(false); + expect(result[1]).toBe(0); + }); + + it('treats an unmined tx (no blockhash) as not complete', async () => { + mockClient.getTx.mockResolvedValueOnce({ txid: 'TX_HASH_01', confirmations: 6, time: 0, amount: 0.5 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBeUndefined(); + expect(result[1]).toBe(0); + }); + + it('treats a missing tx as not complete', async () => { + mockClient.getTx.mockResolvedValueOnce(null); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result[0]).toBeNull(); + expect(result[1]).toBe(0); + }); + }); + + describe('getCurrentFeeRate()', () => { + it('delegates to the fee service', async () => { + await expect(service.getCurrentFeeRate()).resolves.toBe(10); + expect(mockFeeService.getSendFeeRate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-icp.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-icp.service.spec.ts new file mode 100644 index 0000000000..8f888b6cc4 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-icp.service.spec.ts @@ -0,0 +1,178 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutInternetComputerService: sendNativeCoin/ + * sendToken catch a TxBroadcastError from the (shared, non-payout-specific) InternetComputerClient + * and re-throw it as a PayoutBroadcastException, so the payout strategy can tell "the IC update call + * was reached" apart from a provable pre-broadcast failure (see InternetComputerStrategy#doPayout + * via PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutInternetComputerService } from '../payout-icp.service'; + +describe('PayoutInternetComputerService', () => { + let client: InternetComputerClient; + let service: PayoutInternetComputerService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + client = mock(); + const internetComputerService = mock(); + jest.spyOn(internetComputerService, 'getDefaultClient').mockReturnValue(client); + sendNativeCoinFromDexSpy = jest.spyOn(client, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(client, 'sendTokenFromDex'); + + service = new PayoutInternetComputerService(internetComputerService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('IC update call rejected'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('IC update call rejected'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('IC update call rejected for token transfer'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('IC update call rejected for token transfer'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [false, 0] and reads no fee while the tx is not complete', async () => { + jest.spyOn(client, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee'); + const tokenGasSpy = jest.spyOn(client, 'getCurrentGasCostForTokenTransaction'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + expect(tokenGasSpy).not.toHaveBeenCalled(); + }); + + it('reads the reverse-gas-model token cost (not the on-chain fee) for a complete token payout', async () => { + const token = createDefaultAsset(); + jest.spyOn(client, 'isTxComplete').mockResolvedValue(true); + const tokenGasSpy = jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(0.0002); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01', token)).resolves.toEqual([true, 0.0002]); + expect(tokenGasSpy).toHaveBeenCalledWith(token); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + + it('reads the actual on-chain fee for a complete coin payout (no token)', async () => { + jest.spyOn(client, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee').mockResolvedValue(0.0001); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.0001]); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('falls back to the coin gas cost when the primary fee lookup throws', async () => { + jest.spyOn(client, 'isTxComplete').mockResolvedValue(true); + jest.spyOn(client, 'getTxActualFee').mockRejectedValue(new Error('ledger query failed')); + const coinGasSpy = jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(0.00005); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.00005]); + expect(coinGasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForCoinTransaction()', () => { + it('delegates to the default client', async () => { + const gasSpy = jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(0.0001); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(0.0001); + expect(gasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + const asset = createDefaultAsset(); + + it('delegates to the default client, forwarding the token', async () => { + const gasSpy = jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(0.0002); + + await expect(service.getCurrentGasForTokenTransaction(asset)).resolves.toBe(0.0002); + expect(gasSpy).toHaveBeenCalledWith(asset); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts new file mode 100644 index 0000000000..c73ff69172 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-lightning.service.spec.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutLightningService: sendPayment catches a + * TxBroadcastError from the (shared, non-payout-specific - also used by PayIn) LightningService and + * re-throws it as a PayoutBroadcastException, so the payout strategy can tell "the LND send-payment + * call was reached" apart from a provable pre-broadcast/in-band failure (see LightningStrategy# + * doPayout via PayoutStrategy#handleBroadcastError). Anything else - including the plain Error + * LightningService#sendTransfer throws for an in-band LND payment_error ("no route" etc., see + * lightning.service.ts) - must propagate unchanged so the order self-heals. + * + * One exception: a keysend (LN_NID) payment carries no invoice payment_hash, so LND cannot + * deduplicate a re-broadcast. Every keysend failure is therefore wrapped fail-closed (as a + * PayoutBroadcastException), even a plain in-band error, to prevent a double-pay on retry. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { LndRouteDto } from 'src/integration/lightning/dto/lnd.dto'; +import { LightningClient } from 'src/integration/lightning/lightning-client'; +import { LightningService } from 'src/integration/lightning/services/lightning.service'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutLightningService } from '../payout-lightning.service'; + +describe('PayoutLightningService', () => { + let lightningService: LightningService; + let client: LightningClient; + let service: PayoutLightningService; + let sendTransferSpy: jest.SpyInstance; + + beforeEach(() => { + lightningService = mock(); + client = mock(); + jest.spyOn(lightningService, 'getDefaultClient').mockReturnValue(client); + sendTransferSpy = jest.spyOn(lightningService, 'sendTransfer'); + + service = new PayoutLightningService(lightningService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendPayment(...)', () => { + it('wraps a TxBroadcastError (transport/timeout to the LND node) into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('socket hang up'); + sendTransferSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendPayment('ADDR_01', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('socket hang up'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates an in-band "no route" payment failure (plain Error) unchanged, so the order self-heals', async () => { + const cause = new Error('Error while sending payment by LNURL ADDR_01: FAILURE_REASON_NO_ROUTE'); + sendTransferSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendPayment('ADDR_01', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('wraps a keysend (LN_NID) in-band failure fail-closed, since it has no payment_hash for LND dedup', async () => { + const cause = new Error('Error while sending payment by LNURL LNNID03aabb: FAILURE_REASON_NO_ROUTE'); + sendTransferSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendPayment('LNNID03aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe(cause.message); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('wraps a keysend (LN_NID) non-Error rejection fail-closed, stringifying the reason', async () => { + sendTransferSpy.mockRejectedValue('raw string failure'); + + let error: unknown; + try { + await service.sendPayment('LNNID03aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('raw string failure'); + expect((error as PayoutBroadcastException).cause).toBe('raw string failure'); + }); + + it('propagates any other non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTransferSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendPayment('ADDR_01', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx id on success and forwards address/amount', async () => { + sendTransferSpy.mockResolvedValue('TX_ID_01'); + + await expect(service.sendPayment('ADDR_01', 0.001)).resolves.toBe('TX_ID_01'); + expect(sendTransferSpy).toHaveBeenCalledWith('ADDR_01', 0.001); + }); + + it('returns the tx id for a keysend (LN_NID) with a non-empty payment hash', async () => { + sendTransferSpy.mockResolvedValue('TX_ID_KEYSEND'); + + await expect(service.sendPayment('LNNID03aabbccddeeff', 0.001)).resolves.toBe('TX_ID_KEYSEND'); + }); + + it('wraps a keysend (LN_NID) empty payment hash fail-closed, since a re-broadcast has no dedup', async () => { + sendTransferSpy.mockResolvedValue(''); + + let error: unknown; + try { + await service.sendPayment('LNNID03aabbccddeeff', 0.001); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Lightning keysend returned an empty payment hash'); + }); + + it('returns an empty payment hash unchanged for a non-keysend (invoice payment_hash dedup covers a retry)', async () => { + sendTransferSpy.mockResolvedValue(''); + + await expect(service.sendPayment('ADDR_01', 0.001)).resolves.toBe(''); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the default client', async () => { + const isHealthySpy = jest.spyOn(client, 'isHealthy').mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getEstimatedFee(...)', () => { + const route = (totalFeesMsat: number): LndRouteDto => ({ + total_fees_msat: totalFeesMsat, + total_amt_msat: totalFeesMsat + 1000, + hops: [{ chan_id: '1', amt_to_forward_msat: 1000, fee_msat: totalFeesMsat, pub_key: 'HOP_PK' }], + }); + + it('resolves the public key, queries routes and returns the most expensive route fee in BTC', async () => { + const getPublicKeySpy = jest.spyOn(lightningService, 'getPublicKeyOfAddress').mockResolvedValue('PUBKEY_01'); + const getRoutesSpy = jest.spyOn(client, 'getLndRoutes').mockResolvedValue([route(1000), route(2000)]); + + // max(1000, 2000, 0) msat = 2000 msat = 2 sat = 0.00000002 BTC + await expect(service.getEstimatedFee('addr@dfx.swiss', 0.001)).resolves.toBe(0.00000002); + expect(getPublicKeySpy).toHaveBeenCalledWith('addr@dfx.swiss'); + expect(getRoutesSpy).toHaveBeenCalledWith('PUBKEY_01', 0.001); + }); + + it('returns 0 when no route is available (Math.max seed)', async () => { + jest.spyOn(lightningService, 'getPublicKeyOfAddress').mockResolvedValue('PUBKEY_01'); + jest.spyOn(client, 'getLndRoutes').mockResolvedValue([]); + + await expect(service.getEstimatedFee('addr@dfx.swiss', 0.001)).resolves.toBe(0); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('delegates to the shared lightning service', async () => { + const completionSpy = jest.spyOn(lightningService, 'getTransferCompletionData'); + completionSpy.mockResolvedValue([true, 0.00000002]); + + await expect(service.getPayoutCompletionData('TX_ID_01')).resolves.toEqual([true, 0.00000002]); + expect(completionSpy).toHaveBeenCalledWith('TX_ID_01'); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-monero.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-monero.service.spec.ts new file mode 100644 index 0000000000..2a97fb5cfd --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-monero.service.spec.ts @@ -0,0 +1,154 @@ +/** + * Unit Tests for PayoutMoneroService + * + * Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + * MoneroClient means the on-chain send was reached (tx may already be relayed) and must surface + * as PayoutBroadcastException so BitcoinBasedStrategy#send keeps the order PAYOUT_DESIGNATED + * (fail-closed) instead of rolling back and risking a double-spend on retry. + */ + +import { MoneroClient } from 'src/integration/blockchain/monero/monero-client'; +import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutGroup } from '../base/payout-bitcoin-based.service'; +import { PayoutMoneroService } from '../payout-monero.service'; + +describe('PayoutMoneroService', () => { + let service: PayoutMoneroService; + let mockClient: jest.Mocked; + let sendTransfersSpy: jest.Mock; + let getTransactionSpy: jest.Mock; + let getUnlockedBalanceSpy: jest.Mock; + let getFeeEstimateSpy: jest.Mock; + let isHealthySpy: jest.Mock; + + beforeEach(() => { + sendTransfersSpy = jest.fn().mockResolvedValue({ txid: 'TX_HASH_01', amount: 1.5, fee: 0.01 }); + getTransactionSpy = jest.fn(); + getUnlockedBalanceSpy = jest.fn(); + getFeeEstimateSpy = jest.fn(); + isHealthySpy = jest.fn(); + + mockClient = { + sendTransfers: sendTransfersSpy, + getTransaction: getTransactionSpy, + getUnlockedBalance: getUnlockedBalanceSpy, + getFeeEstimate: getFeeEstimateSpy, + } as unknown as jest.Mocked; + + const mockMoneroService = { + getDefaultClient: jest.fn().mockReturnValue(mockClient), + isHealthy: isHealthySpy, + } as unknown as jest.Mocked; + + service = new PayoutMoneroService(mockMoneroService); + }); + + describe('sendToMany()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + + const result = await service.sendToMany(PayoutOrderContext.BUY_CRYPTO, payout); + + expect(result).toBe('TX_HASH_01'); + }); + + it('should wrap a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const cause = new TxBroadcastError('Failed to send tx'); + sendTransfersSpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Failed to send tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the client unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const plainError = new Error('unrelated client error'); + sendTransfersSpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendToMany(PayoutOrderContext.BUY_CRYPTO, payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('throws a descriptive error when a successful call returns no transfer', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + sendTransfersSpy.mockResolvedValueOnce(undefined); + + await expect(service.sendToMany(PayoutOrderContext.BUY_CRYPTO, payout)).rejects.toThrow( + 'Error while sending payment by Monero ADDR_01', + ); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the shared service', async () => { + isHealthySpy.mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUnlockedBalance()', () => { + it('delegates to the default client', async () => { + getUnlockedBalanceSpy.mockResolvedValue(3.5); + + await expect(service.getUnlockedBalance()).resolves.toBe(3.5); + expect(getUnlockedBalanceSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPayoutCompletionData()', () => { + it('returns [true, txnFee] once the tx is mined with confirmations', async () => { + getTransactionSpy.mockResolvedValue({ block_height: 100, confirmations: 5, txnFee: 0.0002 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.0002]); + expect(getTransactionSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('defaults the fee to 0 for a complete tx that carries no txnFee', async () => { + getTransactionSpy.mockResolvedValue({ block_height: 100, confirmations: 5 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0]); + }); + + it('returns [false, 0] while the tx has no confirmations', async () => { + getTransactionSpy.mockResolvedValue({ block_height: 100, confirmations: 0, txnFee: 0.0002 }); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([false, 0]); + }); + }); + + describe('getEstimatedFee()', () => { + it('returns the slow-priority base fee from the estimate', async () => { + getFeeEstimateSpy.mockResolvedValue({ fee: 0, fees: [0.00012, 0.0002, 0.0003, 0.0004], status: 'OK' }); + + await expect(service.getEstimatedFee()).resolves.toBe(0.00012); + expect(getFeeEstimateSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-solana.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-solana.service.spec.ts new file mode 100644 index 0000000000..8d8df5c8bb --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-solana.service.spec.ts @@ -0,0 +1,158 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutSolanaService: sendNativeCoin/sendToken + * catch a TxBroadcastError from the (shared, non-payout-specific) SolanaClient and re-throw it as a + * PayoutBroadcastException, so the payout strategy can tell "the RPC send call was reached" apart + * from a provable pre-broadcast failure (see SolanaStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; +import { SolanaClient } from 'src/integration/blockchain/solana/solana-client'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutSolanaService } from '../payout-solana.service'; + +describe('PayoutSolanaService', () => { + let client: SolanaClient; + let service: PayoutSolanaService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + client = mock(); + const solanaService = mock(); + jest.spyOn(solanaService, 'getDefaultClient').mockReturnValue(client); + sendNativeCoinFromDexSpy = jest.spyOn(client, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(client, 'sendTokenFromDex'); + + service = new PayoutSolanaService(solanaService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('rpc rejected the tx'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('rpc rejected the tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('rpc rejected the token tx'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('rpc rejected the token tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] and reads the actual fee once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(client, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee').mockResolvedValue(0.000005); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.000005]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + const isTxCompleteSpy = jest.spyOn(client, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(client, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentGasForCoinTransaction()', () => { + it('delegates to the default client', async () => { + const gasSpy = jest.spyOn(client, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(0.000001); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(0.000001); + expect(gasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + const asset = createDefaultAsset(); + + it('delegates to the default client, forwarding the token', async () => { + const gasSpy = jest.spyOn(client, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(0.000002); + + await expect(service.getCurrentGasForTokenTransaction(asset)).resolves.toBe(0.000002); + expect(gasSpy).toHaveBeenCalledWith(asset); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-spark.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-spark.service.spec.ts new file mode 100644 index 0000000000..676aaac290 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-spark.service.spec.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutSparkService: sendTransaction catches a + * TxBroadcastError from the (shared, non-payout-specific) SparkService/SparkClient and re-throws it + * as a PayoutBroadcastException, so the payout strategy can tell "wallet.transfer was reached" apart + * from a provable pre-broadcast failure (see SparkStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { SparkClient } from 'src/integration/blockchain/spark/spark-client'; +import { SparkService } from 'src/integration/blockchain/spark/spark.service'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutSparkService } from '../payout-spark.service'; + +describe('PayoutSparkService', () => { + let sparkService: SparkService; + let sparkClient: SparkClient; + let service: PayoutSparkService; + let sendTransactionSpy: jest.SpyInstance; + + beforeEach(() => { + sparkService = mock(); + sparkClient = mock(); + jest.spyOn(sparkService, 'getDefaultClient').mockReturnValue(sparkClient); + sendTransactionSpy = jest.spyOn(sparkService, 'sendTransaction'); + + service = new PayoutSparkService(sparkService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendTransaction(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('transfer failed'); + sendTransactionSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendTransaction('SPARK_ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('transfer failed'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTransactionSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendTransaction('SPARK_ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the txid on success and forwards address/amount', async () => { + sendTransactionSpy.mockResolvedValue({ txid: 'TX_HASH_01', fee: 0 }); + + await expect(service.sendTransaction('SPARK_ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendTransactionSpy).toHaveBeenCalledWith('SPARK_ADDR_01', 1.5); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the shared service', async () => { + const isHealthySpy = jest.spyOn(sparkService, 'isHealthy').mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(sparkClient, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(sparkService, 'getTxActualFee').mockResolvedValue(0.0001); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 0.0001]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + jest.spyOn(sparkClient, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(sparkService, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentFeeForTransaction(...)', () => { + it('returns the native fee for a coin asset', async () => { + const coin = createCustomAsset({ type: AssetType.COIN }); + const getNativeFeeSpy = jest.spyOn(sparkService, 'getNativeFee').mockResolvedValue(0.00001); + + await expect(service.getCurrentFeeForTransaction(coin)).resolves.toBe(0.00001); + expect(getNativeFeeSpy).toHaveBeenCalledTimes(1); + }); + + it('throws for a non-coin asset without touching the native fee', () => { + const token = createDefaultAsset(); // AssetType.TOKEN + const getNativeFeeSpy = jest.spyOn(sparkService, 'getNativeFee'); + + expect(() => service.getCurrentFeeForTransaction(token)).toThrow('Method not implemented'); + expect(getNativeFeeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-tron.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-tron.service.spec.ts new file mode 100644 index 0000000000..c0df2aae02 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-tron.service.spec.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for the broadcast-boundary mapping in PayoutTronService: sendNativeCoin/sendToken catch + * a TxBroadcastError from the (shared, non-payout-specific) TronService/TronClient and re-throw it as + * a PayoutBroadcastException, so the payout strategy can tell "the atomic sign+broadcast gateway call + * was reached" apart from a provable pre-broadcast failure (see TronStrategy#doPayout via + * PayoutStrategy#handleBroadcastError). Anything else must propagate unchanged. + */ + +import { mock } from 'jest-mock-extended'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { TronService } from 'src/integration/blockchain/tron/services/tron.service'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutTronService } from '../payout-tron.service'; + +describe('PayoutTronService', () => { + let tronService: TronService; + let service: PayoutTronService; + let sendNativeCoinFromDexSpy: jest.SpyInstance; + let sendTokenFromDexSpy: jest.SpyInstance; + + beforeEach(() => { + tronService = mock(); + sendNativeCoinFromDexSpy = jest.spyOn(tronService, 'sendNativeCoinFromDex'); + sendTokenFromDexSpy = jest.spyOn(tronService, 'sendTokenFromDex'); + + service = new PayoutTronService(tronService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('sendNativeCoin(...)', () => { + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('gateway rejected the tx'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('gateway rejected the tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendNativeCoinFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendNativeCoin('ADDR_01', 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/amount', async () => { + sendNativeCoinFromDexSpy.mockResolvedValue('TX_HASH_01'); + + await expect(service.sendNativeCoin('ADDR_01', 1.5)).resolves.toBe('TX_HASH_01'); + expect(sendNativeCoinFromDexSpy).toHaveBeenCalledWith('ADDR_01', 1.5); + }); + }); + + describe('sendToken(...)', () => { + const asset = createDefaultAsset(); + + it('wraps a TxBroadcastError from the client into a PayoutBroadcastException, keeping message and cause', async () => { + const cause = new TxBroadcastError('gateway rejected the trc20 tx'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('gateway rejected the trc20 tx'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('propagates a non-TxBroadcastError unchanged', async () => { + const cause = new Error('unexpected failure'); + sendTokenFromDexSpy.mockRejectedValue(cause); + + let error: unknown; + try { + await service.sendToken('ADDR_01', asset, 1); + } catch (e) { + error = e; + } + + expect(error).toBe(cause); // same object - not wrapped + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + + it('returns the tx hash on success and forwards address/asset/amount', async () => { + sendTokenFromDexSpy.mockResolvedValue('TX_HASH_02'); + + await expect(service.sendToken('ADDR_01', asset, 2.5)).resolves.toBe('TX_HASH_02'); + expect(sendTokenFromDexSpy).toHaveBeenCalledWith('ADDR_01', asset, 2.5); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + it('returns [true, actualFee] and reads the actual fee once the tx is complete', async () => { + const isTxCompleteSpy = jest.spyOn(tronService, 'isTxComplete').mockResolvedValue(true); + const getTxActualFeeSpy = jest.spyOn(tronService, 'getTxActualFee').mockResolvedValue(1.1); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([true, 1.1]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] and never reads the fee while the tx is not complete', async () => { + const isTxCompleteSpy = jest.spyOn(tronService, 'isTxComplete').mockResolvedValue(false); + const getTxActualFeeSpy = jest.spyOn(tronService, 'getTxActualFee'); + + await expect(service.getPayoutCompletionData('TX_HASH_01')).resolves.toEqual([false, 0]); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(getTxActualFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentGasForCoinTransaction()', () => { + it('delegates to the shared service', async () => { + const gasSpy = jest.spyOn(tronService, 'getCurrentGasCostForCoinTransaction').mockResolvedValue(1.5); + + await expect(service.getCurrentGasForCoinTransaction()).resolves.toBe(1.5); + expect(gasSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCurrentGasForTokenTransaction(...)', () => { + const asset = createDefaultAsset(); + + it('delegates to the shared service, forwarding the token', async () => { + const gasSpy = jest.spyOn(tronService, 'getCurrentGasCostForTokenTransaction').mockResolvedValue(2.7); + + await expect(service.getCurrentGasForTokenTransaction(asset)).resolves.toBe(2.7); + expect(gasSpy).toHaveBeenCalledWith(asset); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-zano.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-zano.service.spec.ts new file mode 100644 index 0000000000..71d97fbd26 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout-zano.service.spec.ts @@ -0,0 +1,218 @@ +/** + * Unit Tests for PayoutZanoService + * + * Mirrors PayoutCardanoService: a TxBroadcastError from the (shared, non-payout-specific) + * ZanoService/ZanoClient means the on-chain send was reached (tx may already be relayed) and must + * surface as PayoutBroadcastException so ZanoStrategy#send (via BitcoinBasedStrategy) keeps the + * order PAYOUT_DESIGNATED (fail-closed) instead of rolling back and risking a double-spend. + */ + +import { ZanoTransactionDto } from 'src/integration/blockchain/zano/dto/zano.dto'; +import { ZanoService } from 'src/integration/blockchain/zano/services/zano.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutOrderContext } from '../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../exceptions/payout-broadcast.exception'; +import { PayoutGroup } from '../base/payout-bitcoin-based.service'; +import { PayoutZanoService } from '../payout-zano.service'; + +describe('PayoutZanoService', () => { + let service: PayoutZanoService; + let sendCoinsSpy: jest.Mock; + let sendTokensSpy: jest.Mock; + let isHealthySpy: jest.Mock; + let getUnlockedCoinBalanceSpy: jest.Mock; + let getUnlockedTokenBalanceSpy: jest.Mock; + let getTransactionSpy: jest.Mock; + let isTxCompleteSpy: jest.Mock; + let getFeeEstimateSpy: jest.Mock; + + beforeEach(() => { + sendCoinsSpy = jest.fn().mockResolvedValue({ txId: 'COIN_TX_01', amount: 1.5, fee: 0.01 }); + sendTokensSpy = jest.fn().mockResolvedValue({ txId: 'TOKEN_TX_01', amount: 1.5, fee: 0.01 }); + isHealthySpy = jest.fn(); + getUnlockedCoinBalanceSpy = jest.fn(); + getUnlockedTokenBalanceSpy = jest.fn(); + getTransactionSpy = jest.fn(); + isTxCompleteSpy = jest.fn(); + getFeeEstimateSpy = jest.fn(); + + const mockZanoService = { + sendCoins: sendCoinsSpy, + sendTokens: sendTokensSpy, + isHealthy: isHealthySpy, + getUnlockedCoinBalance: getUnlockedCoinBalanceSpy, + getUnlockedTokenBalance: getUnlockedTokenBalanceSpy, + getTransaction: getTransactionSpy, + isTxComplete: isTxCompleteSpy, + getFeeEstimate: getFeeEstimateSpy, + } as unknown as jest.Mocked; + + service = new PayoutZanoService(mockZanoService); + }); + + describe('sendCoins()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + + const result = await service.sendCoins(payout); + + expect(result).toBe('COIN_TX_01'); + }); + + it('should wrap a TxBroadcastError from the service into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const cause = new TxBroadcastError('Transfer not sent: response was {}'); + sendCoinsSpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Transfer not sent: response was {}'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the service unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const plainError = new Error('Unlocked coin balance 0 less than amount + fee 1.51'); + sendCoinsSpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendCoins(payout); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('sendTokens()', () => { + it('should propagate the tx id on success', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + + const result = await service.sendTokens(payout, createDefaultAsset()); + + expect(result).toBe('TOKEN_TX_01'); + }); + + it('should wrap a TxBroadcastError from the service into a PayoutBroadcastException, keeping message and cause', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const cause = new TxBroadcastError('Transfer not sent: response was {}'); + sendTokensSpy.mockRejectedValueOnce(cause); + + let error: unknown; + try { + await service.sendTokens(payout, createDefaultAsset()); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(PayoutBroadcastException); + expect((error as PayoutBroadcastException).message).toBe('Transfer not sent: response was {}'); + expect((error as PayoutBroadcastException).cause).toBe(cause); + }); + + it('should propagate a non-TxBroadcastError from the service unchanged', async () => { + const payout: PayoutGroup = [{ addressTo: 'ADDR_01', amount: 1.5 }]; + const plainError = new Error('Unlocked token balance 0 less than token amount 1.5'); + sendTokensSpy.mockRejectedValueOnce(plainError); + + let error: unknown; + try { + await service.sendTokens(payout, createDefaultAsset()); + } catch (e) { + error = e; + } + + expect(error).toBe(plainError); + expect(error).not.toBeInstanceOf(PayoutBroadcastException); + }); + }); + + describe('isHealthy()', () => { + it('delegates to the shared service', async () => { + isHealthySpy.mockResolvedValue(true); + + await expect(service.isHealthy()).resolves.toBe(true); + expect(isHealthySpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUnlockedCoinBalance()', () => { + it('delegates to the shared service', async () => { + getUnlockedCoinBalanceSpy.mockResolvedValue(12.5); + + await expect(service.getUnlockedCoinBalance()).resolves.toBe(12.5); + expect(getUnlockedCoinBalanceSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUnlockedTokenBalance(...)', () => { + it('delegates to the shared service, forwarding the token', async () => { + const token = createDefaultAsset(); + getUnlockedTokenBalanceSpy.mockResolvedValue(7.25); + + await expect(service.getUnlockedTokenBalance(token)).resolves.toBe(7.25); + expect(getUnlockedTokenBalanceSpy).toHaveBeenCalledWith(token); + }); + }); + + describe('getPayoutCompletionData(...)', () => { + const completeTx: ZanoTransactionDto = { + id: 'TX_HASH_01', + block: 100, + amount: 1.5, + fee: 0.01, + status: 'confirmed', + timestamp: '1700000000', + }; + + it('returns [true, fee] read from the fetched tx once it is complete', async () => { + getTransactionSpy.mockResolvedValue(completeTx); + isTxCompleteSpy.mockResolvedValue(true); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0.01]); + expect(getTransactionSpy).toHaveBeenCalledWith('TX_HASH_01'); + expect(isTxCompleteSpy).toHaveBeenCalledWith('TX_HASH_01'); + }); + + it('returns [false, 0] while the tx is not complete', async () => { + getTransactionSpy.mockResolvedValue(completeTx); + isTxCompleteSpy.mockResolvedValue(false); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([false, 0]); + }); + + it('defaults the fee to 0 for a complete but fee-less tx (defensive ?? guard)', async () => { + // fee is a required field on ZanoTransactionDto, so simulate a malformed/fee-less response + // to exercise the `transaction.fee ?? 0` fallback branch. + getTransactionSpy.mockResolvedValue({ ...completeTx, fee: undefined } as unknown as ZanoTransactionDto); + isTxCompleteSpy.mockResolvedValue(true); + + const result = await service.getPayoutCompletionData(PayoutOrderContext.BUY_CRYPTO, 'TX_HASH_01'); + + expect(result).toEqual([true, 0]); + }); + }); + + describe('getEstimatedFee()', () => { + it('delegates to the shared service fee estimate', () => { + getFeeEstimateSpy.mockReturnValue(0.005); + + expect(service.getEstimatedFee()).toBe(0.005); + expect(getFeeEstimateSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts new file mode 100644 index 0000000000..3231b0cca9 --- /dev/null +++ b/src/subdomains/supporting/payout/services/__tests__/payout.service.spec.ts @@ -0,0 +1,873 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { mock } from 'jest-mock-extended'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import * as processServiceModule from 'src/shared/services/process.service'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { In, MoreThan } from 'typeorm'; +import { createCustomPayoutOrder } from '../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../entities/payout-order.entity'; +import { PayoutOrderFactory } from '../../factories/payout-order.factory'; +import { PayoutRequest } from '../../interfaces'; +import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; +import { PayoutStrategy } from '../../strategies/payout/impl/base/payout.strategy'; +import { PayoutStrategyRegistry } from '../../strategies/payout/impl/base/payout.strategy-registry'; +import { PrepareStrategyRegistry } from '../../strategies/prepare/impl/base/prepare.strategy-registry'; +import { PayoutLogService } from '../payout-log.service'; +import { PayoutService } from '../payout.service'; + +describe('PayoutService', () => { + describe('#speedupTransaction(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutStrategyRegistry: PayoutStrategyRegistry; + let doPayoutSpy: jest.Mock; + let disabledProcessSpy: jest.SpyInstance; + + // supportsSpeedup is a read-only getter; build a plain typed stub instead of a jest-mock-extended + // proxy so the boolean is deterministic (the proxy would auto-mock the getter to a truthy fn). + function setupStrategy(supportsSpeedup: boolean): void { + doPayoutSpy = jest.fn(); + const strategy = { supportsSpeedup, doPayout: doPayoutSpy } as unknown as PayoutStrategy; + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockReturnValue(strategy); + } + + beforeEach(() => { + payoutOrderRepo = mock(); + payoutStrategyRegistry = mock(); + + // TX_SPEEDUP is fail-closed (disabled) by default in tests; enable it so the speedup path runs. + disabledProcessSpy = jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + + // Default to a speedup-capable (EVM-like) strategy. + setupStrategy(true); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + payoutStrategyRegistry, + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('rejects a speedup on an order that has not been broadcast yet (guards against a fresh double-payout)', async () => { + const order = createCustomPayoutOrder({ id: 1, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + await expect(service.speedupTransaction(order.id)).rejects.toThrow(BadRequestException); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + + it('accelerates an already-broadcast (PAYOUT_PENDING) order on a speedup-capable (EVM) strategy', async () => { + const order = createCustomPayoutOrder({ + id: 2, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'PTX_01', + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + await service.speedupTransaction(order.id); + + expect(doPayoutSpy).toHaveBeenCalledWith([order]); + }); + + it('rejects a speedup on a chain without replacement semantics (supportsSpeedup=false) and never broadcasts', async () => { + setupStrategy(false); + const order = createCustomPayoutOrder({ + id: 3, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'PTX_02', + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + await expect(service.speedupTransaction(order.id)).rejects.toThrow(BadRequestException); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + + it('rejects a speedup when TX_SPEEDUP is disabled, even on a speedup-capable strategy', async () => { + disabledProcessSpy.mockReturnValue(true); + const order = createCustomPayoutOrder({ + id: 4, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'PTX_03', + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + await expect(service.speedupTransaction(order.id)).rejects.toThrow(BadRequestException); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException for an unknown order id and never consults the strategy registry', async () => { + const getStrategySpy = jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy'); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(null); + + await expect(service.speedupTransaction(999)).rejects.toThrow(NotFoundException); + expect(getStrategySpy).not.toHaveBeenCalled(); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + }); + + describe('#processOrders(...) — cron-level reboot guarantee', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutStrategyRegistry: PayoutStrategyRegistry; + let notificationService: NotificationService; + let doPayoutSpy: jest.Mock; + + // Dispatches payoutOrderRepo.findBy(...) by the status it was queried with, mirroring the + // distinct status-scoped queries each cron sub-step (checkExistingOrders/payoutOrders/ + // processFailedOrders) issues against the same table. + function mockFindByStatus(map: Partial>): void { + jest.spyOn(payoutOrderRepo, 'findBy').mockImplementation(async (where: { status?: PayoutOrderStatus }) => { + return (where.status && map[where.status]) ?? []; + }); + } + + beforeEach(() => { + payoutOrderRepo = mock(); + payoutStrategyRegistry = mock(); + notificationService = mock(); + + // getLatestOrderDate() resolves to "now" so the debounce (waitForStableInput) is NOT yet + // elapsed: prepareNewOrders() returns early and never queries findBy({status: CREATED}), + // which is irrelevant to the reboot guarantee under test here. + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue({ created: new Date() } as PayoutOrder); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + + doPayoutSpy = jest.fn(); + const strategy = { supportsSpeedup: false, doPayout: doPayoutSpy } as unknown as PayoutStrategy; + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockReturnValue(strategy); + + service = new PayoutService( + mock(), + notificationService, + payoutOrderRepo, + mock(), + payoutStrategyRegistry, + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('dispatches only PREPARATION_CONFIRMED orders to doPayout; a crashed PAYOUT_DESIGNATED order is skipped', async () => { + const confirmedOrder = createCustomPayoutOrder({ id: 10, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + const crashedOrder = createCustomPayoutOrder({ id: 11, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + + mockFindByStatus({ + [PayoutOrderStatus.PREPARATION_PENDING]: [], + [PayoutOrderStatus.PAYOUT_PENDING]: [], + [PayoutOrderStatus.PREPARATION_CONFIRMED]: [confirmedOrder], + [PayoutOrderStatus.PAYOUT_DESIGNATED]: [crashedOrder], + }); + + await service.processOrders(); + + expect(doPayoutSpy).toHaveBeenCalledTimes(1); + expect(doPayoutSpy).toHaveBeenCalledWith([confirmedOrder]); + expect(doPayoutSpy).not.toHaveBeenCalledWith(expect.arrayContaining([crashedOrder])); + }); + + it('processFailedOrders marks a stuck order PAYOUT_UNCERTAIN, persists it and alerts via mail (no doPayout)', async () => { + const crashedOrder = createCustomPayoutOrder({ id: 12, status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([crashedOrder]); + + await service['processFailedOrders'](); + + expect(crashedOrder.status).toBe(PayoutOrderStatus.PAYOUT_UNCERTAIN); + expect(saveSpy).toHaveBeenCalledWith(crashedOrder); + expect(sendMailSpy).toHaveBeenCalledTimes(1); + expect(doPayoutSpy).not.toHaveBeenCalled(); + }); + + describe('#payoutOrders(...)', () => { + it('queries PREPARATION_CONFIRMED orders, resolves a strategy per order and dispatches doPayout with the grouped orders', async () => { + const confirmedOrder = createCustomPayoutOrder({ id: 20, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([confirmedOrder]); + const getStrategySpy = jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy'); + + await service['payoutOrders'](); + + expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + expect(getStrategySpy).toHaveBeenCalledWith(confirmedOrder.asset); + expect(doPayoutSpy).toHaveBeenCalledTimes(1); + expect(doPayoutSpy).toHaveBeenCalledWith([confirmedOrder]); + }); + + it('skips an order without a resolvable strategy and logs a warning (groupByStrategies branch)', async () => { + const orphanOrder = createCustomPayoutOrder({ id: 21, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orphanOrder]); + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockReturnValue(undefined); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + await service['payoutOrders'](); + + expect(doPayoutSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`for payout order ID ${orphanOrder.id}`)); + }); + + it('isolates a throwing payout strategy: logs the error and continues without crashing the cron step (catch branch)', async () => { + const confirmedOrder = createCustomPayoutOrder({ id: 22, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([confirmedOrder]); + const throwingDoPayout = jest.fn().mockRejectedValue(new Error('boom')); + jest + .spyOn(payoutStrategyRegistry, 'getPayoutStrategy') + .mockReturnValue({ doPayout: throwingDoPayout } as unknown as PayoutStrategy); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['payoutOrders']()).resolves.toBeUndefined(); + + expect(throwingDoPayout).toHaveBeenCalledWith([confirmedOrder]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('isolates a throwing payout-strategy group from a second, healthy strategy group (cross-group isolation)', async () => { + const assetA = createCustomAsset({ id: 201 }); + const assetB = createCustomAsset({ id: 202 }); + const orderA = createCustomPayoutOrder({ + id: 23, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + asset: assetA, + }); + const orderB = createCustomPayoutOrder({ + id: 24, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + asset: assetB, + }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB]); + + const throwingDoPayout = jest.fn().mockRejectedValue(new Error('boom')); + const healthyDoPayout = jest.fn().mockResolvedValue(undefined); + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockImplementation((asset) => { + if (asset === assetA) return { doPayout: throwingDoPayout } as unknown as PayoutStrategy; + if (asset === assetB) return { doPayout: healthyDoPayout } as unknown as PayoutStrategy; + return undefined; + }); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['payoutOrders']()).resolves.toBeUndefined(); + + expect(throwingDoPayout).toHaveBeenCalledWith([orderA]); + // group B's doPayout still runs despite group A's throw: proves catch-isolation across groups, not merely within one + expect(healthyDoPayout).toHaveBeenCalledWith([orderB]); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('#processFailedOrders(...)', () => { + it('does nothing when there is no PAYOUT_DESIGNATED order (no mail, no save)', async () => { + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([]); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + const sendMailSpy = jest.spyOn(notificationService, 'sendMail'); + + await service['processFailedOrders'](); + + expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.PAYOUT_DESIGNATED }); + expect(sendMailSpy).not.toHaveBeenCalled(); + expect(saveSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('#getPayoutOrders(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + + beforeEach(() => { + payoutOrderRepo = mock(); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('queries orders created after the given date and forwards the requested relations', async () => { + const from = new Date('2026-01-01'); + const relations = { asset: true }; + const orders = [createCustomPayoutOrder({ id: 100 })]; + const findSpy = jest.spyOn(payoutOrderRepo, 'find').mockResolvedValue(orders); + + const result = await service.getPayoutOrders(from, relations); + + expect(findSpy).toHaveBeenCalledWith({ where: { created: MoreThan(from) }, relations }); + expect(result).toBe(orders); + }); + + it('queries orders without relations when none are provided', async () => { + const from = new Date('2026-01-01'); + const findSpy = jest.spyOn(payoutOrderRepo, 'find').mockResolvedValue([]); + + await service.getPayoutOrders(from); + + expect(findSpy).toHaveBeenCalledWith({ where: { created: MoreThan(from) }, relations: undefined }); + }); + }); + + describe('#doPayout(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutOrderFactory: PayoutOrderFactory; + let disabledProcessSpy: jest.SpyInstance; + + const baseRequest: PayoutRequest = { + context: PayoutOrderContext.BUY_CRYPTO, + correlationId: 'CID_99', + asset: createDefaultAsset(), + amount: 5, + destinationAddress: 'ADDR_99', + }; + + beforeEach(() => { + payoutOrderRepo = mock(); + payoutOrderFactory = mock(); + + // CRYPTO_PAYOUT is fail-closed (disabled) by default in tests; enable it so the happy path runs. + disabledProcessSpy = jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + payoutOrderFactory, + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('creates and persists a new payout order when the process is enabled and the amount is valid', async () => { + const order = createCustomPayoutOrder({ id: 101 }); + const createSpy = jest.spyOn(payoutOrderFactory, 'createOrder').mockReturnValue(order); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save').mockResolvedValue(order); + + await service.doPayout(baseRequest); + + expect(createSpy).toHaveBeenCalledWith(baseRequest); + expect(saveSpy).toHaveBeenCalledWith(order); + }); + + it('accepts a zero-amount payout (boundary of the amount >= 0 guard)', async () => { + const zeroRequest = { ...baseRequest, amount: 0 }; + const order = createCustomPayoutOrder({ id: 102, amount: 0 }); + jest.spyOn(payoutOrderFactory, 'createOrder').mockReturnValue(order); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save').mockResolvedValue(order); + + await service.doPayout(zeroRequest); + + expect(saveSpy).toHaveBeenCalledWith(order); + }); + + it('rejects with a generic error and logs when CRYPTO_PAYOUT is disabled (fail-closed)', async () => { + disabledProcessSpy.mockReturnValue(true); + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service.doPayout(baseRequest)).rejects.toThrow( + `Error while trying to create PayoutOrder for context ${baseRequest.context} and correlationId: ${baseRequest.correlationId}`, + ); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('rejects with a generic error and logs when the requested amount is negative', async () => { + const negativeRequest = { ...baseRequest, amount: -1 }; + const saveSpy = jest.spyOn(payoutOrderRepo, 'save'); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service.doPayout(negativeRequest)).rejects.toThrow( + `Error while trying to create PayoutOrder for context ${negativeRequest.context} and correlationId: ${negativeRequest.correlationId}`, + ); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('#checkOrderCompletion(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + + beforeEach(() => { + payoutOrderRepo = mock(); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('reports isComplete=true with the payout details for a COMPLETE order', async () => { + const order = createCustomPayoutOrder({ + id: 110, + status: PayoutOrderStatus.COMPLETE, + payoutTxId: 'PTX_110', + amount: 3, + }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + const result = await service.checkOrderCompletion(order.context, order.correlationId); + + expect(result.isComplete).toBe(true); + expect(result.payoutTxId).toBe('PTX_110'); + expect(result.payoutAmount).toBe(3); + expect(result.payoutAsset).toBe(order.asset); + expect(result.payoutFee).toEqual(order.payoutFee); + }); + + it('reports isComplete=false for an order found in a non-COMPLETE status', async () => { + const order = createCustomPayoutOrder({ id: 111, status: PayoutOrderStatus.PAYOUT_PENDING }); + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(order); + + const result = await service.checkOrderCompletion(order.context, order.correlationId); + + expect(result.isComplete).toBe(false); + }); + + it('returns falsy fields when no matching order exists', async () => { + jest.spyOn(payoutOrderRepo, 'findOneBy').mockResolvedValue(null); + + const result = await service.checkOrderCompletion(PayoutOrderContext.BUY_CRYPTO, 'UNKNOWN'); + + expect(result.isComplete).toBeFalsy(); + expect(result.payoutTxId).toBeFalsy(); + expect(result.payoutFee).toBeFalsy(); + expect(result.payoutAmount).toBeFalsy(); + expect(result.payoutAsset).toBeFalsy(); + }); + }); + + describe('#estimateFee(...) / #estimateBlockchainFee(...)', () => { + let service: PayoutService; + let payoutStrategyRegistry: PayoutStrategyRegistry; + let prepareStrategyRegistry: PrepareStrategyRegistry; + + beforeEach(() => { + payoutStrategyRegistry = mock(); + prepareStrategyRegistry = mock(); + + service = new PayoutService( + mock(), + mock(), + mock(), + mock(), + payoutStrategyRegistry, + prepareStrategyRegistry, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('combines the prepare fee and the payout fee into a single rounded total fee', async () => { + const targetAsset = createDefaultAsset(); + const asset = createDefaultAsset(); + const feeAsset = createDefaultAsset(); + + const prepareEstimateSpy = jest.fn().mockResolvedValue({ asset: feeAsset, amount: 0.1 }); + const payoutEstimateSpy = jest.fn().mockResolvedValue({ asset: feeAsset, amount: 0.2 }); + + jest + .spyOn(prepareStrategyRegistry, 'getPrepareStrategy') + .mockReturnValue({ estimateFee: prepareEstimateSpy } as any); + jest + .spyOn(payoutStrategyRegistry, 'getPayoutStrategy') + .mockReturnValue({ estimateFee: payoutEstimateSpy } as any); + + const result = await service.estimateFee(targetAsset, 'ADDR', 1, asset); + + expect(prepareEstimateSpy).toHaveBeenCalledWith(targetAsset); + expect(payoutEstimateSpy).toHaveBeenCalledWith(targetAsset, 'ADDR', 1, asset); + // amount checked separately with toBeCloseTo: prepareFee (0.1) + payoutFee (0.2) hits a binary + // floating-point artifact (0.30000000000000004) that Util.round(...,16) cannot fully absorb + // (rounding to 16 decimals is at the edge of double precision), so an exact toEqual on the + // whole object would be flaky. toBeCloseTo still proves both fees were summed into ~0.3. + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.3, 8); + }); + + it('delegates blockchain-fee estimation to the payout strategy resolved for the asset', async () => { + const asset = createDefaultAsset(); + const feeResult = { asset, amount: 0.05 }; + const estimateBlockchainFeeSpy = jest.fn().mockResolvedValue(feeResult); + + jest + .spyOn(payoutStrategyRegistry, 'getPayoutStrategy') + .mockReturnValue({ estimateBlockchainFee: estimateBlockchainFeeSpy } as any); + + const result = await service.estimateBlockchainFee(asset); + + expect(estimateBlockchainFeeSpy).toHaveBeenCalledWith(asset); + expect(result).toBe(feeResult); + }); + }); + + describe('#getRecentPayoutSentCorrelationIds(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + + beforeEach(() => { + payoutOrderRepo = mock(); + + service = new PayoutService( + mock(), + mock(), + payoutOrderRepo, + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('queries recently sent/completed orders for the context and returns a deduplicated set of correlationIds', async () => { + const orderA = createCustomPayoutOrder({ id: 150, correlationId: 'CID_A' }); + const orderB = createCustomPayoutOrder({ id: 151, correlationId: 'CID_B' }); + const orderDuplicate = createCustomPayoutOrder({ id: 152, correlationId: 'CID_A' }); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB, orderDuplicate]); + + const result = await service.getRecentPayoutSentCorrelationIds(PayoutOrderContext.BUY_CRYPTO); + + expect(findBySpy).toHaveBeenCalledWith( + expect.objectContaining({ + context: PayoutOrderContext.BUY_CRYPTO, + status: In([PayoutOrderStatus.PAYOUT_PENDING, PayoutOrderStatus.COMPLETE]), + }), + ); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(2); + expect(result).toEqual(new Set(['CID_A', 'CID_B'])); + }); + }); + + describe('#checkPreparationCompletion(...) / #checkPayoutCompletion(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let prepareStrategyRegistry: PrepareStrategyRegistry; + let payoutStrategyRegistry: PayoutStrategyRegistry; + let logs: PayoutLogService; + + beforeEach(() => { + payoutOrderRepo = mock(); + prepareStrategyRegistry = mock(); + payoutStrategyRegistry = mock(); + logs = mock(); + + service = new PayoutService( + logs, + mock(), + payoutOrderRepo, + mock(), + payoutStrategyRegistry, + prepareStrategyRegistry, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('checks preparation completion per strategy group and logs the newly confirmed orders', async () => { + const order = createCustomPayoutOrder({ id: 120, status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + const checkSpy = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(prepareStrategyRegistry, 'getPrepareStrategy') + .mockReturnValue({ checkPreparationCompletion: checkSpy } as any); + const logSpy = jest.spyOn(logs, 'logTransferCompletion'); + + await service['checkPreparationCompletion'](); + + expect(checkSpy).toHaveBeenCalledWith([order]); + expect(logSpy).toHaveBeenCalledWith([order]); + }); + + it('isolates a throwing prepare strategy: logs the error and continues without crashing the cron step', async () => { + const order = createCustomPayoutOrder({ id: 121, status: PayoutOrderStatus.PREPARATION_PENDING }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockReturnValue({ + checkPreparationCompletion: jest.fn().mockRejectedValue(new Error('boom')), + } as any); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['checkPreparationCompletion']()).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('isolates a throwing prepare-strategy group from a second, healthy strategy group (cross-group isolation)', async () => { + const assetA = createCustomAsset({ id: 211 }); + const assetB = createCustomAsset({ id: 212 }); + const orderA = createCustomPayoutOrder({ id: 126, status: PayoutOrderStatus.PREPARATION_PENDING, asset: assetA }); + const orderB = createCustomPayoutOrder({ id: 127, status: PayoutOrderStatus.PREPARATION_PENDING, asset: assetB }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB]); + + const throwingCheck = jest.fn().mockRejectedValue(new Error('boom')); + const healthyCheck = jest.fn().mockResolvedValue(undefined); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockImplementation((asset) => { + if (asset === assetA) return { checkPreparationCompletion: throwingCheck } as any; + if (asset === assetB) return { checkPreparationCompletion: healthyCheck } as any; + return undefined; + }); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['checkPreparationCompletion']()).resolves.toBeUndefined(); + + expect(throwingCheck).toHaveBeenCalledWith([orderA]); + // group B's checkPreparationCompletion still runs despite group A's throw: proves catch-isolation across groups + expect(healthyCheck).toHaveBeenCalledWith([orderB]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('checks payout completion per strategy group and logs the newly completed orders', async () => { + const order = createCustomPayoutOrder({ id: 122, status: PayoutOrderStatus.COMPLETE }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + const checkSpy = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(payoutStrategyRegistry, 'getPayoutStrategy') + .mockReturnValue({ checkPayoutCompletionData: checkSpy } as any); + const logSpy = jest.spyOn(logs, 'logPayoutCompletion'); + + await service['checkPayoutCompletion'](); + + expect(checkSpy).toHaveBeenCalledWith([order]); + expect(logSpy).toHaveBeenCalledWith([order]); + }); + + it('isolates a throwing payout strategy: logs the error and continues without crashing the cron step', async () => { + const order = createCustomPayoutOrder({ id: 123, status: PayoutOrderStatus.PAYOUT_PENDING }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockReturnValue({ + checkPayoutCompletionData: jest.fn().mockRejectedValue(new Error('boom')), + } as any); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['checkPayoutCompletion']()).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('isolates a throwing payout-strategy group from a second, healthy strategy group (cross-group isolation)', async () => { + const assetA = createCustomAsset({ id: 221 }); + const assetB = createCustomAsset({ id: 222 }); + const orderA = createCustomPayoutOrder({ id: 128, status: PayoutOrderStatus.PAYOUT_PENDING, asset: assetA }); + const orderB = createCustomPayoutOrder({ id: 129, status: PayoutOrderStatus.PAYOUT_PENDING, asset: assetB }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB]); + + const throwingCheck = jest.fn().mockRejectedValue(new Error('boom')); + const healthyCheck = jest.fn().mockResolvedValue(undefined); + jest.spyOn(payoutStrategyRegistry, 'getPayoutStrategy').mockImplementation((asset) => { + if (asset === assetA) return { checkPayoutCompletionData: throwingCheck } as any; + if (asset === assetB) return { checkPayoutCompletionData: healthyCheck } as any; + return undefined; + }); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['checkPayoutCompletion']()).resolves.toBeUndefined(); + + expect(throwingCheck).toHaveBeenCalledWith([orderA]); + // group B's checkPayoutCompletionData still runs despite group A's throw: proves catch-isolation across groups + expect(healthyCheck).toHaveBeenCalledWith([orderB]); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('#prepareNewOrders(...)', () => { + let service: PayoutService; + let payoutOrderRepo: PayoutOrderRepository; + let prepareStrategyRegistry: PrepareStrategyRegistry; + let logs: PayoutLogService; + + beforeEach(() => { + payoutOrderRepo = mock(); + prepareStrategyRegistry = mock(); + logs = mock(); + + service = new PayoutService( + logs, + mock(), + payoutOrderRepo, + mock(), + mock(), + prepareStrategyRegistry, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('skips preparing new orders while the latest order is within the debounce window', async () => { + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue({ created: new Date() } as PayoutOrder); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy'); + + await service['prepareNewOrders'](); + + expect(findBySpy).not.toHaveBeenCalled(); + }); + + it('treats an empty order table (no latest order date) as stable and proceeds to prepare', async () => { + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue(null); + const order = createCustomPayoutOrder({ id: 130, status: PayoutOrderStatus.PREPARATION_PENDING }); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + const prepareSpy = jest.fn().mockResolvedValue(undefined); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockReturnValue({ preparePayout: prepareSpy } as any); + const logSpy = jest.spyOn(logs, 'logNewPayoutOrders'); + + await service['prepareNewOrders'](); + + expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.CREATED }); + expect(prepareSpy).toHaveBeenCalledWith([order]); + expect(logSpy).toHaveBeenCalledWith([order]); + }); + + it('once the debounce time has elapsed, prepares CREATED orders per strategy group', async () => { + const oldDate = new Date(Date.now() - 60_000); + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue({ created: oldDate } as PayoutOrder); + const order = createCustomPayoutOrder({ id: 131, status: PayoutOrderStatus.CREATED }); + const findBySpy = jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + const prepareSpy = jest.fn().mockResolvedValue(undefined); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockReturnValue({ preparePayout: prepareSpy } as any); + + await service['prepareNewOrders'](); + + expect(findBySpy).toHaveBeenCalledWith({ status: PayoutOrderStatus.CREATED }); + expect(prepareSpy).toHaveBeenCalledWith([order]); + }); + + it('isolates a throwing prepare strategy during new-order preparation: logs the error and continues', async () => { + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue(null); + const order = createCustomPayoutOrder({ id: 132, status: PayoutOrderStatus.CREATED }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([order]); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockReturnValue({ + preparePayout: jest.fn().mockRejectedValue(new Error('boom')), + } as any); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['prepareNewOrders']()).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalled(); + }); + + it('isolates a throwing prepare-strategy group from a second, healthy strategy group during new-order preparation (cross-group isolation)', async () => { + jest.spyOn(payoutOrderRepo, 'findOne').mockResolvedValue(null); + const assetA = createCustomAsset({ id: 231 }); + const assetB = createCustomAsset({ id: 232 }); + const orderA = createCustomPayoutOrder({ id: 133, status: PayoutOrderStatus.CREATED, asset: assetA }); + const orderB = createCustomPayoutOrder({ id: 134, status: PayoutOrderStatus.CREATED, asset: assetB }); + jest.spyOn(payoutOrderRepo, 'findBy').mockResolvedValue([orderA, orderB]); + + const throwingPrepare = jest.fn().mockRejectedValue(new Error('boom')); + const healthyPrepare = jest.fn().mockResolvedValue(undefined); + jest.spyOn(prepareStrategyRegistry, 'getPrepareStrategy').mockImplementation((asset) => { + if (asset === assetA) return { preparePayout: throwingPrepare } as any; + if (asset === assetB) return { preparePayout: healthyPrepare } as any; + return undefined; + }); + const errorSpy = jest.spyOn(service['logger'], 'error'); + + await expect(service['prepareNewOrders']()).resolves.toBeUndefined(); + + expect(throwingPrepare).toHaveBeenCalledWith([orderA]); + // group B's preparePayout still runs despite group A's throw: proves catch-isolation across groups + expect(healthyPrepare).toHaveBeenCalledWith([orderB]); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('#groupByStrategies(...)', () => { + let service: PayoutService; + + beforeEach(() => { + service = new PayoutService( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('accumulates multiple orders resolving to the same strategy into a single group (existing-group branch)', () => { + const sharedStrategy = { name: 'shared-strategy' }; + const orderA = createCustomPayoutOrder({ id: 140 }); + const orderB = createCustomPayoutOrder({ id: 141 }); + const getter = jest.fn().mockReturnValue(sharedStrategy); + + const groups = service['groupByStrategies']([orderA, orderB], getter); + + expect(groups.size).toBe(1); + expect(groups.get(sharedStrategy)).toEqual([orderA, orderB]); + }); + + it('skips an order without a resolvable strategy and logs a warning', () => { + const orphanOrder = createCustomPayoutOrder({ id: 142 }); + const getter = jest.fn().mockReturnValue(undefined); + const warnSpy = jest.spyOn(service['logger'], 'warn'); + + const groups = service['groupByStrategies']([orphanOrder], getter); + + expect(groups.size).toBe(0); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`for payout order ID ${orphanOrder.id}`)); + }); + }); + + describe('#createMailRequest(...)', () => { + let service: PayoutService; + + beforeEach(() => { + service = new PayoutService( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); + }); + + it('defaults orders to an empty list, yielding an empty correlationId', () => { + const request = service['createMailRequest']('Payout failed'); + + expect(request.correlationId).toBe(''); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/services/payout-arkade.service.ts b/src/subdomains/supporting/payout/services/payout-arkade.service.ts index 92589cf07a..27c5b08ac3 100644 --- a/src/subdomains/supporting/payout/services/payout-arkade.service.ts +++ b/src/subdomains/supporting/payout/services/payout-arkade.service.ts @@ -1,13 +1,21 @@ import { Injectable } from '@nestjs/common'; import { ArkadeService } from 'src/integration/blockchain/arkade/arkade.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutArkadeService { constructor(private readonly arkadeService: ArkadeService) {} async sendTransaction(address: string, amount: number): Promise { - return this.arkadeService.sendTransaction(address, amount).then((r) => r.txid); + try { + const result = await this.arkadeService.sendTransaction(address, amount); + return result.txid; + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async isHealthy(): Promise { diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts index e962ad03f6..365254a12e 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts @@ -5,7 +5,9 @@ import { BitcoinTestnet4NodeType, BitcoinTestnet4Service, } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { PayoutOrderContext } from '../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; @Injectable() @@ -28,7 +30,13 @@ export class PayoutBitcoinTestnet4Service extends PayoutBitcoinBasedService { async sendUtxoToMany(_context: PayoutOrderContext, payout: PayoutGroup): Promise { const feeRate = await this.getCurrentFeeRate(); - return this.client.sendMany(payout, feeRate); + + try { + return await this.client.sendMany(payout, feeRate); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(_context: PayoutOrderContext, payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts index 28ba7bc8ff..d1929a0708 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts @@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common'; import { BitcoinClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; import { BitcoinNodeType, BitcoinService } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Util } from 'src/shared/utils/util'; import { PayoutOrderContext } from '../entities/payout-order.entity'; import { InvalidPayoutAmountException } from '../exceptions/invalid-payout-amount.exception'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; // Bitcoin Core's send/sendmany RPC parses amount fields with ParseFixedPoint(decimals=8) @@ -38,7 +40,12 @@ export class PayoutBitcoinService extends PayoutBitcoinBasedService { const sanitizedPayout = this.sanitizePayoutAmounts(payout); const feeRate = Util.round(await this.getCurrentFeeRate(), BTC_FEE_RATE_DECIMALS); - return this.client.sendMany(sanitizedPayout, feeRate); + try { + return await this.client.sendMany(sanitizedPayout, feeRate); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(_context: any, payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-cardano.service.ts b/src/subdomains/supporting/payout/services/payout-cardano.service.ts index fe77e6ed4e..27f157e069 100644 --- a/src/subdomains/supporting/payout/services/payout-cardano.service.ts +++ b/src/subdomains/supporting/payout/services/payout-cardano.service.ts @@ -1,17 +1,29 @@ import { Injectable } from '@nestjs/common'; import { CardanoService } from 'src/integration/blockchain/cardano/services/cardano.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutCardanoService { constructor(private readonly cardanoService: CardanoService) {} async sendNativeCoin(address: string, amount: number): Promise { - return this.cardanoService.sendNativeCoinFromDex(address, amount); + try { + return await this.cardanoService.sendNativeCoinFromDex(address, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } - async sendToken(address: string, token: Asset, amount: number) { - return this.cardanoService.sendTokenFromDex(address, token, amount); + async sendToken(address: string, token: Asset, amount: number): Promise { + try { + return await this.cardanoService.sendTokenFromDex(address, token, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-evm.service.ts b/src/subdomains/supporting/payout/services/payout-evm.service.ts index d3a28ea29d..cf3235ab4f 100644 --- a/src/subdomains/supporting/payout/services/payout-evm.service.ts +++ b/src/subdomains/supporting/payout/services/payout-evm.service.ts @@ -1,6 +1,8 @@ +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; import { EvmService } from 'src/integration/blockchain/shared/evm/evm.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutTxStatus } from '../interfaces'; export abstract class PayoutEvmService { @@ -11,11 +13,21 @@ export abstract class PayoutEvmService { } async sendNativeCoin(address: string, amount: number, nonce?: number): Promise { - return this.client.sendNativeCoinFromDex(address, amount, nonce); + try { + return await this.client.sendNativeCoinFromDex(address, amount, nonce); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async sendToken(address: string, tokenName: Asset, amount: number, nonce?: number): Promise { - return this.client.sendTokenFromDex(address, tokenName, amount, nonce); + try { + return await this.client.sendTokenFromDex(address, tokenName, amount, nonce); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string): Promise { diff --git a/src/subdomains/supporting/payout/services/payout-firo.service.ts b/src/subdomains/supporting/payout/services/payout-firo.service.ts index 7f88b2f101..ba0ed01d62 100644 --- a/src/subdomains/supporting/payout/services/payout-firo.service.ts +++ b/src/subdomains/supporting/payout/services/payout-firo.service.ts @@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common'; import { FiroClient } from 'src/integration/blockchain/firo/firo-client'; import { FiroFeeService } from 'src/integration/blockchain/firo/services/firo-fee.service'; import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { PayoutOrderContext } from '../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; @Injectable() @@ -28,12 +30,24 @@ export class PayoutFiroService extends PayoutBitcoinBasedService { async sendUtxoToMany(_context: PayoutOrderContext, payout: PayoutGroup): Promise { const feeRate = await this.getCurrentFeeRate(); - return this.client.sendMany(payout, feeRate); + + try { + return await this.client.sendMany(payout, feeRate); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async mintSpark(payout: PayoutGroup): Promise { const recipients = payout.map((p) => ({ address: p.addressTo, amount: p.amount })); - return this.client.mintSpark(recipients); + + try { + return await this.client.mintSpark(recipients); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(_context: PayoutOrderContext, payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-icp.service.ts b/src/subdomains/supporting/payout/services/payout-icp.service.ts index 7728a4f16a..e170215525 100644 --- a/src/subdomains/supporting/payout/services/payout-icp.service.ts +++ b/src/subdomains/supporting/payout/services/payout-icp.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutInternetComputerService { @@ -12,11 +14,21 @@ export class PayoutInternetComputerService { } async sendNativeCoin(address: string, amount: number): Promise { - return this.client.sendNativeCoinFromDex(address, amount); + try { + return await this.client.sendNativeCoinFromDex(address, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async sendToken(address: string, token: Asset, amount: number): Promise { - return this.client.sendTokenFromDex(address, token, amount); + try { + return await this.client.sendTokenFromDex(address, token, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string, token?: Asset): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-lightning.service.ts b/src/subdomains/supporting/payout/services/payout-lightning.service.ts index 97e3d15cf4..4dd8c896bc 100644 --- a/src/subdomains/supporting/payout/services/payout-lightning.service.ts +++ b/src/subdomains/supporting/payout/services/payout-lightning.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { LightningClient } from 'src/integration/lightning/lightning-client'; -import { LightningHelper } from 'src/integration/lightning/lightning-helper'; +import { LightningAddressType, LightningHelper } from 'src/integration/lightning/lightning-helper'; import { LightningService } from 'src/integration/lightning/services/lightning.service'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutLightningService { @@ -26,7 +28,27 @@ export class PayoutLightningService { } async sendPayment(address: string, amount: number): Promise { - return this.lightningService.sendTransfer(address, amount); + // A keysend (LN_NID) payment carries no invoice payment_hash, so LND cannot deduplicate a + // re-broadcast - a self-healing retry could double-pay. Unlike an invoice payment (LN_URL / + // LND_HUB), we therefore treat every keysend outcome that is not a confirmed send as ambiguous + // (fail-closed): keep the order PAYOUT_DESIGNATED for escalation instead of rolling it back. + const isKeysend = address.startsWith(LightningAddressType.LN_NID); + + try { + const txId = await this.lightningService.sendTransfer(address, amount); + + // An empty payment hash (e.g. a blank base64 hash) is returned without throwing; for a keysend + // it would otherwise roll the order back and re-broadcast, so treat it as a broadcast error. + if (isKeysend && !txId) throw new PayoutBroadcastException('Lightning keysend returned an empty payment hash'); + + return txId; + } catch (e) { + if (e instanceof PayoutBroadcastException) throw e; + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + if (isKeysend) throw new PayoutBroadcastException(e instanceof Error ? e.message : String(e), { cause: e }); + + throw e; + } } async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-monero.service.ts b/src/subdomains/supporting/payout/services/payout-monero.service.ts index 3add184551..44a1692859 100644 --- a/src/subdomains/supporting/payout/services/payout-monero.service.ts +++ b/src/subdomains/supporting/payout/services/payout-monero.service.ts @@ -1,9 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { BaseFeePriority } from 'src/integration/blockchain/monero/dto/monero.dto'; +import { BaseFeePriority, MoneroTransferDto } from 'src/integration/blockchain/monero/dto/monero.dto'; import { MoneroClient } from 'src/integration/blockchain/monero/monero-client'; import { MoneroHelper } from 'src/integration/blockchain/monero/monero-helper'; import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { PayoutOrderContext } from '../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; @Injectable() @@ -25,7 +27,13 @@ export class PayoutMoneroService extends PayoutBitcoinBasedService { } async sendToMany(_context: PayoutOrderContext, payout: PayoutGroup): Promise { - const transfer = await this.client.sendTransfers(payout); + let transfer: MoneroTransferDto; + try { + transfer = await this.client.sendTransfers(payout); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } if (!transfer) { throw new Error(`Error while sending payment by Monero ${payout.map((p) => p.addressTo)}`); diff --git a/src/subdomains/supporting/payout/services/payout-solana.service.ts b/src/subdomains/supporting/payout/services/payout-solana.service.ts index 4d79949b61..df74c47fec 100644 --- a/src/subdomains/supporting/payout/services/payout-solana.service.ts +++ b/src/subdomains/supporting/payout/services/payout-solana.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; import { SolanaClient } from 'src/integration/blockchain/solana/solana-client'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutSolanaService { @@ -12,11 +14,21 @@ export class PayoutSolanaService { } async sendNativeCoin(address: string, amount: number): Promise { - return this.client.sendNativeCoinFromDex(address, amount); + try { + return await this.client.sendNativeCoinFromDex(address, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } - async sendToken(address: string, token: Asset, amount: number) { - return this.client.sendTokenFromDex(address, token, amount); + async sendToken(address: string, token: Asset, amount: number): Promise { + try { + return await this.client.sendTokenFromDex(address, token, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-spark.service.ts b/src/subdomains/supporting/payout/services/payout-spark.service.ts index 05e760157a..467368e2f1 100644 --- a/src/subdomains/supporting/payout/services/payout-spark.service.ts +++ b/src/subdomains/supporting/payout/services/payout-spark.service.ts @@ -1,13 +1,21 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { SparkService } from 'src/integration/blockchain/spark/spark.service'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutSparkService { constructor(private readonly sparkService: SparkService) {} async sendTransaction(address: string, amount: number): Promise { - return this.sparkService.sendTransaction(address, amount).then((r) => r.txid); + try { + const result = await this.sparkService.sendTransaction(address, amount); + return result.txid; + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async isHealthy(): Promise { diff --git a/src/subdomains/supporting/payout/services/payout-tron.service.ts b/src/subdomains/supporting/payout/services/payout-tron.service.ts index b0f93618a0..8c9ac273d5 100644 --- a/src/subdomains/supporting/payout/services/payout-tron.service.ts +++ b/src/subdomains/supporting/payout/services/payout-tron.service.ts @@ -1,17 +1,29 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { TronService } from 'src/integration/blockchain/tron/services/tron.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; @Injectable() export class PayoutTronService { constructor(private readonly tronService: TronService) {} async sendNativeCoin(address: string, amount: number): Promise { - return this.tronService.sendNativeCoinFromDex(address, amount); + try { + return await this.tronService.sendNativeCoinFromDex(address, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } - async sendToken(address: string, token: Asset, amount: number) { - return this.tronService.sendTokenFromDex(address, token, amount); + async sendToken(address: string, token: Asset, amount: number): Promise { + try { + return await this.tronService.sendTokenFromDex(address, token, amount); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(txHash: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout-zano.service.ts b/src/subdomains/supporting/payout/services/payout-zano.service.ts index 7f585ffd33..885109798d 100644 --- a/src/subdomains/supporting/payout/services/payout-zano.service.ts +++ b/src/subdomains/supporting/payout/services/payout-zano.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; +import { TxBroadcastError } from 'src/integration/blockchain/shared/errors/tx-broadcast.error'; import { ZanoService } from 'src/integration/blockchain/zano/services/zano.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutBroadcastException } from '../exceptions/payout-broadcast.exception'; import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; @Injectable() @@ -22,11 +24,21 @@ export class PayoutZanoService extends PayoutBitcoinBasedService { } async sendCoins(payout: PayoutGroup): Promise { - return this.zanoService.sendCoins(payout).then((r) => r.txId); + try { + return await this.zanoService.sendCoins(payout).then((r) => r.txId); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async sendTokens(payout: PayoutGroup, token: Asset): Promise { - return this.zanoService.sendTokens(payout, token).then((r) => r.txId); + try { + return await this.zanoService.sendTokens(payout, token).then((r) => r.txId); + } catch (e) { + if (e instanceof TxBroadcastError) throw new PayoutBroadcastException(e.message, { cause: e }); + throw e; + } } async getPayoutCompletionData(_context: any, payoutTxId: string): Promise<[boolean, number]> { diff --git a/src/subdomains/supporting/payout/services/payout.service.ts b/src/subdomains/supporting/payout/services/payout.service.ts index da3b82e7c1..389bae6207 100644 --- a/src/subdomains/supporting/payout/services/payout.service.ts +++ b/src/subdomains/supporting/payout/services/payout.service.ts @@ -110,8 +110,22 @@ export class PayoutService { const order = await this.payoutOrderRepo.findOneBy({ id }); if (!order) throw new NotFoundException('Payout order not found'); + // Only an already-broadcast order (payoutTxId set) may be sped up. Any other status would + // enter doPayout without a payoutTxId, trip the designate-before-broadcast guard and trigger + // a fresh broadcast — a double-payout risk on an order the operator only meant to accelerate. + if (order.status !== PayoutOrderStatus.PAYOUT_PENDING) + throw new BadRequestException( + `Payout order ${id} cannot be sped up in status ${order.status}, expected ${PayoutOrderStatus.PAYOUT_PENDING}`, + ); + const strategy = this.payoutStrategyRegistry.getPayoutStrategy(order.asset); + // Speedup reuses the pending tx's nonce to replace it; only EVM implements that (and only while + // TX_SPEEDUP is enabled). On any other chain doPayout would broadcast a second, independent + // transaction while the original stays valid — a double payout. Reject rather than risk it. + if (!strategy.supportsSpeedup || DisabledProcess(Process.TX_SPEEDUP)) + throw new BadRequestException(`Payout order ${id} does not support transaction speedup`); + await strategy.doPayout([order]); } diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-bitcoin-based.strategy.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-bitcoin-based.strategy.spec.ts index 0b1ff1e655..360bb91546 100644 --- a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-bitcoin-based.strategy.spec.ts +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-bitcoin-based.strategy.spec.ts @@ -1,17 +1,25 @@ import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; +import * as processServiceModule from 'src/shared/services/process.service'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; import { createCustomPayoutOrder, createDefaultPayoutOrder, } from '../../../entities/__mocks__/payout-order.entity.mock'; -import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../exceptions/payout-broadcast.exception'; import { FeeResult } from '../../../interfaces'; import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; -import { PayoutBitcoinBasedService } from '../../../services/base/payout-bitcoin-based.service'; +import { PayoutBitcoinBasedService, PayoutGroup } from '../../../services/base/payout-bitcoin-based.service'; import { BitcoinBasedStrategy } from '../impl/base/bitcoin-based.strategy'; describe('PayoutBitcoinBasedStrategy', () => { @@ -232,6 +240,321 @@ describe('PayoutBitcoinBasedStrategy', () => { }); }); + describe('#send(...)', () => { + it('completes the payout and resets retry tracking on a successful dispatch', async () => { + const orders = [ + createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null, retryCount: 2 }), + ]; + strategy.dispatchPayoutImpl = () => Promise.resolve('CHAIN_TX_ID'); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(orders[0].payoutTxId).toBe('CHAIN_TX_ID'); + expect(orders[0].retryCount).toBe(0); + }); + + // The structural fix under test: only a PayoutBroadcastException (client reached the actual + // on-chain broadcast call) must stay fail-closed. This replaces the old `e.message.includes + // ('timeout')` string heuristic. + it('keeps the order PAYOUT_DESIGNATED (fail-closed) when dispatchPayout throws a PayoutBroadcastException', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + strategy.dispatchPayoutImpl = () => Promise.reject(new PayoutBroadcastException('node unreachable after send')); + + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).rejects.toBeInstanceOf( + PayoutBroadcastException, + ); + + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + }); + + it('rolls back to PREPARATION_CONFIRMED (self-heals) when dispatchPayout throws a plain Error', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('Invalid amount')); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(orders[0].status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); + + // Regression test for the removed heuristic: a pre-broadcast RPC timeout (e.g. fee + // estimation) is a plain Error and must now self-heal instead of incorrectly staying + // fail-closed just because its message happens to contain the word "timeout". + it('rolls back (self-heals) a pre-broadcast plain Error whose message contains "timeout"', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('RPC call timeout during fee estimation')); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(orders[0].status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); + + it('stays fail-closed for a PayoutBroadcastException even without the word "timeout" in the message', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + strategy.dispatchPayoutImpl = () => Promise.reject(new PayoutBroadcastException('connection reset')); + + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).rejects.toBeInstanceOf( + PayoutBroadcastException, + ); + + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + }); + + it('records the failure via trackPayoutFailure on both pre- and at-or-after-broadcast errors', async () => { + const preBroadcastOrder = createCustomPayoutOrder({ + id: 20, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + const postBroadcastOrder = createCustomPayoutOrder({ + id: 21, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + + strategy.dispatchPayoutImpl = () => Promise.reject(new Error('pre-broadcast failure')); + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [preBroadcastOrder]); + expect(preBroadcastOrder.retryCount).toBe(1); + expect(preBroadcastOrder.lastError).toBe('pre-broadcast failure'); + + strategy.dispatchPayoutImpl = () => Promise.reject(new PayoutBroadcastException('post-broadcast failure')); + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [postBroadcastOrder])).rejects.toBeInstanceOf( + PayoutBroadcastException, + ); + expect(postBroadcastOrder.retryCount).toBe(1); + expect(postBroadcastOrder.lastError).toBe('post-broadcast failure'); + }); + + it('throws (without persisting) when an order already has a payoutTxId and TX_SPEEDUP is enabled', async () => { + // speedup would reuse an existing txId; the Bitcoin path does not implement it, so it must + // fail-fast before touching the repo. DisabledProcess defaults to true (fail-closed) in tests, + // so force it false to make `!DisabledProcess(TX_SPEEDUP)` true and reach the guard. + const disabledSpy = jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + const orders = [ + createCustomPayoutOrder({ id: 40, status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: 'EXISTING_TX' }), + ]; + + await expect(strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).rejects.toThrowError( + 'Transaction speedup is not implemented for Bitcoin', + ); + expect(repoSaveSpy).not.toHaveBeenCalled(); + + disabledSpy.mockRestore(); + }); + + it('does not trip the speedup guard when TX_SPEEDUP is disabled, even if an order has a payoutTxId', async () => { + // Default test env: DisabledProcess(TX_SPEEDUP) === true, so `!DisabledProcess(...)` is false and + // the guard is skipped; the order is dispatched through the standard path. + const order = createCustomPayoutOrder({ + id: 41, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: 'PTX_EXISTING', + }); + strategy.dispatchPayoutImpl = () => Promise.resolve('CHAIN_TX_ID'); + repoSaveSpy.mockImplementation(async (o: PayoutOrder) => o as PayoutOrder); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('CHAIN_TX_ID'); + }); + + it('sends a non-recoverable error mail when persisting the paid order fails after a successful dispatch', async () => { + const order = createCustomPayoutOrder({ + id: 30, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + }); + strategy.dispatchPayoutImpl = () => Promise.resolve('CHAIN_TX_ID'); + // designatePayout persists PAYOUT_DESIGNATED fine; only the final PAYOUT_PENDING save fails. + repoSaveSpy.mockImplementation(async (o: PayoutOrder) => { + if (o.status === PayoutOrderStatus.PAYOUT_PENDING) throw new Error('db down'); + return o; + }); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await strategy.sendWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error on saving payout payoutTxId to the database'), + expect.any(Error), + ); + expect(sendErrorMailSpy).toHaveBeenCalledTimes(1); + expect(sendErrorMailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ErrorMonitoring', + context: 'Payout', + correlationId: 'PayoutOrder&BuyCrypto&30', + input: expect.objectContaining({ subject: 'Payout Error', isLiqMail: true }), + options: { suppressRecurring: true }, + }), + ); + }); + }); + + describe('#estimateBlockchainFee(...)', () => { + it('delegates to estimateFee(...) with the given asset', async () => { + const asset = createCustomAsset({ id: 7 }); + const feeResult: FeeResult = { asset: createDefaultAsset(), amount: 0.5 }; + strategy.estimateFeeImpl = () => Promise.resolve(feeResult); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toBe(feeResult); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + }); + + describe('#doPayout(...)', () => { + it('groups orders by context and dispatches each healthy context to doPayoutForContext(...)', async () => { + const buyCryptoOrders = [ + createCustomPayoutOrder({ id: 1, context: PayoutOrderContext.BUY_CRYPTO }), + createCustomPayoutOrder({ id: 2, context: PayoutOrderContext.BUY_CRYPTO }), + ]; + const manualOrder = createCustomPayoutOrder({ id: 3, context: PayoutOrderContext.MANUAL }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + const doPayoutForContextSpy = jest.spyOn(strategy, 'doPayoutForContextImpl'); + + await strategy.doPayout([...buyCryptoOrders, manualOrder]); + + expect(bitcoinService.isHealthy).toHaveBeenCalledWith(PayoutOrderContext.BUY_CRYPTO); + expect(bitcoinService.isHealthy).toHaveBeenCalledWith(PayoutOrderContext.MANUAL); + expect(doPayoutForContextSpy).toHaveBeenCalledTimes(2); + expect(doPayoutForContextSpy).toHaveBeenCalledWith(PayoutOrderContext.BUY_CRYPTO, buyCryptoOrders); + expect(doPayoutForContextSpy).toHaveBeenCalledWith(PayoutOrderContext.MANUAL, [manualOrder]); + }); + + it('skips a context whose service is unhealthy', async () => { + const orders = [createCustomPayoutOrder({ id: 1, context: PayoutOrderContext.BUY_CRYPTO })]; + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(false); + const doPayoutForContextSpy = jest.spyOn(strategy, 'doPayoutForContextImpl'); + + await strategy.doPayout(orders); + + expect(doPayoutForContextSpy).not.toHaveBeenCalled(); + }); + + it('catches and logs errors without throwing', async () => { + const orders = [createCustomPayoutOrder({ id: 1, context: PayoutOrderContext.BUY_CRYPTO })]; + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + strategy.doPayoutForContextImpl = () => Promise.reject(new Error('context boom')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await expect(strategy.doPayout(orders)).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error while executing Bitcoin payout orders:', expect.any(Error)); + }); + }); + + describe('#checkPayoutCompletionData(...)', () => { + let pricingService: PricingService; + let convertFn: jest.Mock; + + beforeEach(() => { + new ConfigService(); // sets the module-level Config (Config.defaultVolumeDecimal used by recordPayoutFee) + pricingService = mock(); + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + convertFn = jest.fn().mockReturnValue(0.99); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + repoSaveSpy.mockImplementation(async (o: PayoutOrder) => o as PayoutOrder); + }); + + it('completes each order, records the proportional payout fee and persists once when the tx is complete', async () => { + const feeAsset = createCustomAsset({ name: 'BTC' }); + strategy.feeAssetValue = feeAsset; + const order1 = createCustomPayoutOrder({ + id: 1, + context: PayoutOrderContext.BUY_CRYPTO, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'BTC_TX', + amount: 1, + }); + const order2 = createCustomPayoutOrder({ + id: 2, + context: PayoutOrderContext.BUY_CRYPTO, + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'BTC_TX', + amount: 3, + }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(bitcoinService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0008]); + const complete1Spy = jest.spyOn(order1, 'complete'); + const recordFee1Spy = jest.spyOn(order1, 'recordPayoutFee'); + const recordFee2Spy = jest.spyOn(order2, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order1, order2]); + + expect(bitcoinService.getPayoutCompletionData).toHaveBeenCalledWith(PayoutOrderContext.BUY_CRYPTO, 'BTC_TX'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(complete1Spy).toHaveBeenCalledTimes(1); + // proportional fee: totalFee 0.0008 over total amount 4 -> 0.0002 (amount 1) and 0.0006 (amount 3) + expect(convertFn).toHaveBeenCalledWith(0.0002, Config.defaultVolumeDecimal); + expect(convertFn).toHaveBeenCalledWith(0.0006, Config.defaultVolumeDecimal); + expect(recordFee1Spy).toHaveBeenCalledWith(feeAsset, 0.0002, 0.99); + expect(recordFee2Spy).toHaveBeenCalledWith(feeAsset, 0.0006, 0.99); + expect(order1.status).toBe(PayoutOrderStatus.COMPLETE); + expect(order2.status).toBe(PayoutOrderStatus.COMPLETE); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); + expect(repoSaveSpy).toHaveBeenCalledWith(order1); + expect(repoSaveSpy).toHaveBeenCalledWith(order2); + }); + + it('leaves the order untouched when the tx is not yet complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'BTC_TX_PENDING' }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(bitcoinService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('skips a context whose service is unhealthy', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'BTC_TX_SKIP' }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(false); + + await strategy.checkPayoutCompletionData([order]); + + expect(bitcoinService.getPayoutCompletionData).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows a per-tx error from getPayoutCompletionData and continues without persisting', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'BTC_TX_ERR' }); + jest.spyOn(bitcoinService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(bitcoinService, 'getPayoutCompletionData').mockRejectedValue(new Error('rpc down')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error while checking payout completion data of payout orders'), + expect.any(Error), + ); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows an error thrown while health-checking and logs it', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'BTC_TX_HEALTH' }); + jest.spyOn(bitcoinService, 'isHealthy').mockRejectedValue(new Error('health boom')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Error while checking payout completion of Bitcoin payout orders:', + expect.any(Error), + ); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + describe('#trackPayoutFailure(...)', () => { it('increments retryCount, persists lastError and lastAttemptDate on every order', async () => { const orders = [createCustomPayoutOrder({ id: 10 }), createCustomPayoutOrder({ id: 11 })]; @@ -313,6 +636,16 @@ describe('PayoutBitcoinBasedStrategy', () => { expect(sendErrorMailSpy).not.toBeCalled(); }); + it('falls back to "Unknown payout error" when the error has no message', async () => { + const orders = [createCustomPayoutOrder({ id: 10 })]; + + await strategy.trackPayoutFailureWrapper(orders, null as unknown as Error); + + expect(orders[0].retryCount).toBe(1); + expect(orders[0].lastError).toBe('Unknown payout error'); + expect(repoSaveSpy).toBeCalledTimes(1); + }); + it('truncates very long error messages to 2048 chars', async () => { const longError = 'X'.repeat(5000); const orders = [createCustomPayoutOrder({ id: 10 })]; @@ -385,6 +718,9 @@ class PayoutBitcoinBasedStrategyWrapper extends BitcoinBasedStrategy { super(notificationService, payoutOrderRepo, bitcoinService); } + // Set per-test so #checkPayoutCompletionData(...) / #estimateFee(...) resolve a known fee asset. + feeAssetValue: Asset = createDefaultAsset(); + get blockchain(): Blockchain { return Blockchain.BITCOIN; } @@ -393,12 +729,26 @@ class PayoutBitcoinBasedStrategyWrapper extends BitcoinBasedStrategy { return AssetType.COIN; } - protected doPayoutForContext(): Promise { - throw new Error('Method not implemented.'); + // Overridable per-test so #doPayout(...) tests can assert delegation / simulate a failing context + // without a real chain client; spied on to capture (context, group) arguments. + doPayoutForContextImpl: (context: PayoutOrderContext, group: PayoutOrder[]) => Promise = () => + Promise.resolve(); + + protected doPayoutForContext(context: PayoutOrderContext, group: PayoutOrder[]): Promise { + return this.doPayoutForContextImpl(context, group); } - protected async dispatchPayout(): Promise { - return 'TX_ID_01'; + // Overridable per-test so #send(...) tests can simulate pre-broadcast vs. at-or-after-broadcast + // failures without needing a real chain client. + dispatchPayoutImpl: (context: PayoutOrderContext, payout: PayoutGroup, token?: Asset) => Promise = () => + Promise.resolve('TX_ID_01'); + + protected async dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup, token?: Asset): Promise { + return this.dispatchPayoutImpl(context, payout, token); + } + + sendWrapper(context: PayoutOrderContext, orders: PayoutOrder[]) { + return this.send(context, orders); } createPayoutGroupsWrapper(orders: PayoutOrder[], maxGroupSize: number) { @@ -425,11 +775,15 @@ class PayoutBitcoinBasedStrategyWrapper extends BitcoinBasedStrategy { return this.trackPayoutFailure(orders, error); } - estimateFee(): Promise { - throw new Error('Method not implemented.'); + // Overridable per-test; #estimateBlockchainFee(...) delegates to this via estimateFee(...). + estimateFeeImpl: (asset: Asset) => Promise = () => + Promise.resolve({ asset: this.feeAssetValue, amount: 0 }); + + estimateFee(asset: Asset): Promise { + return this.estimateFeeImpl(asset); } protected getFeeAsset(): Promise { - throw new Error('Method not implemented.'); + return Promise.resolve(this.feeAssetValue); } } diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts new file mode 100644 index 0000000000..d5ae58091b --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-designate-before-broadcast.strategy.spec.ts @@ -0,0 +1,334 @@ +import { mock } from 'jest-mock-extended'; +import { ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../exceptions/payout-broadcast.exception'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutArkadeService } from '../../../services/payout-arkade.service'; +import { PayoutCardanoService } from '../../../services/payout-cardano.service'; +import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { PayoutLightningService } from '../../../services/payout-lightning.service'; +import { PayoutSolanaService } from '../../../services/payout-solana.service'; +import { PayoutSparkService } from '../../../services/payout-spark.service'; +import { PayoutTronService } from '../../../services/payout-tron.service'; +import { ArkadeStrategy } from '../impl/arkade.strategy'; +import { EvmStrategy } from '../impl/base/evm.strategy'; +import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; +import { InternetComputerCoinStrategy } from '../impl/icp-coin.strategy'; +import { LightningStrategy } from '../impl/lightning.strategy'; +import { SolanaCoinStrategy } from '../impl/solana-coin.strategy'; +import { SparkStrategy } from '../impl/spark.strategy'; +import { TronCoinStrategy } from '../impl/tron-coin.strategy'; + +// Fixture returned by every per-strategy setup function below: a strategy instance whose +// broadcast sink (dispatchPayout/sendTransaction/sendPayment) and persistence sink +// (payoutOrderRepo.save) are individually spy-able, so the shared variant suite can drive and +// assert designate-before-broadcast behavior identically across all eight strategies. +interface DesignateBeforeBroadcastFixture { + doPayout: (orders: PayoutOrder[]) => Promise; + dispatchSpy: jest.SpyInstance; + repoSaveSpy: jest.SpyInstance; +} + +interface DesignateBeforeBroadcastOptions { + // Error thrown by dispatchPayout to simulate a broadcast-time failure. Plain Error by default + // (matches every strategy that still has the old, undifferentiated fail-closed catch). EVM, + // Cardano, Solana, ICP, Lightning, Tron, Arkade and Spark now map a real send failure to + // PayoutBroadcastException (see payout-evm/cardano/solana/icp/lightning/tron/arkade/spark.service.ts + // + evm/cardano/solana/icp/tron/arkade/spark-client.ts and lightning-client.ts), so their fixtures + // must throw that type here to stay representative. + broadcastError?: Error; + // PayoutStrategy#handleBroadcastError (see payout.strategy.ts, exercised via EvmStrategy / + // CardanoStrategy / SolanaStrategy #doPayout) treats any non-PayoutBroadcastException + // before/at the pre-broadcast designate-save as provably pre-broadcast and rolls back for + // retry - including a failed designate save itself, since dispatchPayout was never reached + // either way. Every other (not-yet-migrated) strategy keeps the old "never touch it again" + // behavior for that case. + designateSaveFailureRollsBack?: boolean; +} + +// Shared table of the four designate-before-broadcast variants, run identically against every +// strategy's fixture. Kept as real, individually-named it() blocks (not a silent for-loop) so a +// failure in one strategy/variant combination is reported on its own line. +function runDesignateBeforeBroadcastSuite( + strategyName: string, + setup: () => DesignateBeforeBroadcastFixture, + options: DesignateBeforeBroadcastOptions = {}, +): void { + const { broadcastError = new Error('broadcast failed'), designateSaveFailureRollsBack = false } = options; + + describe(`${strategyName} #doPayout(...)`, () => { + beforeAll(() => { + new ConfigService(); // sets module-level Config (Config.payout.maxPreBroadcastRetries used by handleBroadcastError) + }); + + it('designates and persists BEFORE broadcasting, then reaches PAYOUT_PENDING with the new txId', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + + // capture the persisted status at the moment of the first (pre-broadcast) save + let statusAtFirstSave: PayoutOrderStatus | undefined; + repoSaveSpy.mockImplementationOnce(async (o: PayoutOrder) => { + statusAtFirstSave = o.status; + return o; + }); + dispatchSpy.mockResolvedValue('TX_NEW'); + + await doPayout([order]); + + expect(statusAtFirstSave).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_NEW'); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); // designate + pending + }); + + it('leaves the order PAYOUT_DESIGNATED (no txId, no rollback) when the broadcast throws', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchSpy.mockRejectedValue(broadcastError); + + await doPayout([order]); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBeNull(); + expect(dispatchSpy).toHaveBeenCalledTimes(1); // no second broadcast + expect(rollbackSpy).not.toHaveBeenCalled(); // fail-closed: never auto-rollback after broadcast + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the pre-broadcast designate save + }); + + it('does NOT re-designate when payoutTxId is already set (speedup/expired-retry path)', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'OLD_TX' }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + dispatchSpy.mockResolvedValue('NEW_TX'); + + await doPayout([order]); + + expect(designateSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('NEW_TX'); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the pending save, no extra designate save + }); + + it('never broadcasts when the pre-broadcast designate save throws', async () => { + const { doPayout, dispatchSpy, repoSaveSpy } = setup(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + repoSaveSpy.mockRejectedValueOnce(new Error('DB unavailable')); + + // doPayout must swallow the error (fail-closed logging), never let it escape + await expect(doPayout([order])).resolves.toBeUndefined(); + + expect(dispatchSpy).not.toHaveBeenCalled(); // broadcast is never reached + + if (designateSaveFailureRollsBack) { + // EVM: the failed designate save is itself proof dispatchPayout was never reached, so the + // differentiated catch rolls back for retry (failed save + rollback save = 2 total). + expect(repoSaveSpy).toHaveBeenCalledTimes(2); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.payoutTxId).toBeNull(); + } else { + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + // order.designatePayout() already ran synchronously before the rejected save, so the + // in-memory object is PAYOUT_DESIGNATED even though nothing was ever persisted - it never + // advances to PAYOUT_PENDING/txId, which is what matters for not being double-broadcast. + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBeNull(); + } + }); + }); +} + +function repoSaveEcho(payoutOrderRepo: PayoutOrderRepository): jest.SpyInstance { + return jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); +} + +function setupEvm(): DesignateBeforeBroadcastFixture { + const payoutEvmService = mock(); + const payoutOrderRepo = mock(); + const dispatchFn = jest.fn(); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, dispatchFn); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy: dispatchFn, repoSaveSpy }; +} + +function setupSolana(): DesignateBeforeBroadcastFixture { + const solanaService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const dispatchSpy = jest.spyOn(solanaService, 'sendNativeCoin'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new SolanaCoinStrategy(solanaService, assetService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupTron(): DesignateBeforeBroadcastFixture { + const tronService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const dispatchSpy = jest.spyOn(tronService, 'sendNativeCoin'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new TronCoinStrategy(tronService, assetService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupCardano(): DesignateBeforeBroadcastFixture { + const cardanoService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const dispatchSpy = jest.spyOn(cardanoService, 'sendNativeCoin'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new CardanoCoinStrategy(cardanoService, assetService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupIcp(): DesignateBeforeBroadcastFixture { + const internetComputerService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const dispatchSpy = jest.spyOn(internetComputerService, 'sendNativeCoin'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new InternetComputerCoinStrategy(internetComputerService, assetService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupArkade(): DesignateBeforeBroadcastFixture { + const arkadeService = mock(); + const payoutOrderRepo = mock(); + const assetService = mock(); + const dispatchSpy = jest.spyOn(arkadeService, 'sendTransaction'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new ArkadeStrategy(arkadeService, payoutOrderRepo, assetService); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupSpark(): DesignateBeforeBroadcastFixture { + const sparkService = mock(); + const payoutOrderRepo = mock(); + const assetService = mock(); + const dispatchSpy = jest.spyOn(sparkService, 'sendTransaction'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new SparkStrategy(sparkService, payoutOrderRepo, assetService); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +function setupLightning(): DesignateBeforeBroadcastFixture { + const assetService = mock(); + const payoutLightningService = mock(); + const payoutOrderRepo = mock(); + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); + const dispatchSpy = jest.spyOn(payoutLightningService, 'sendPayment'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new LightningStrategy(assetService, payoutLightningService, payoutOrderRepo); + + return { doPayout: (orders) => strategy.doPayout(orders), dispatchSpy, repoSaveSpy }; +} + +describe('Payout designate-before-broadcast', () => { + runDesignateBeforeBroadcastSuite('EvmStrategy', setupEvm, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('SolanaStrategy (SolanaCoinStrategy)', setupSolana, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('TronStrategy (TronCoinStrategy)', setupTron, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('CardanoStrategy (CardanoCoinStrategy)', setupCardano, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('IcpStrategy (InternetComputerCoinStrategy)', setupIcp, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('ArkadeStrategy', setupArkade, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('SparkStrategy', setupSpark, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + runDesignateBeforeBroadcastSuite('LightningStrategy', setupLightning, { + broadcastError: new PayoutBroadcastException('broadcast failed'), + designateSaveFailureRollsBack: true, + }); + + describe('LightningStrategy #doPayout(...) — health gate', () => { + it('designates nothing, broadcasts nothing and saves nothing when isHealthy() is false', async () => { + const assetService = mock(); + const payoutLightningService = mock(); + const payoutOrderRepo = mock(); + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(false); + const sendPaymentSpy = jest.spyOn(payoutLightningService, 'sendPayment'); + const repoSaveSpy = repoSaveEcho(payoutOrderRepo); + + const strategy = new LightningStrategy(assetService, payoutLightningService, payoutOrderRepo); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + + await strategy.doPayout([order]); + + expect(designateSpy).not.toHaveBeenCalled(); + expect(sendPaymentSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.payoutTxId).toBeNull(); + }); + }); +}); + +class EvmStrategyWrapper extends EvmStrategy { + constructor( + payoutEvmService: PayoutEvmService, + payoutOrderRepo: PayoutOrderRepository, + private readonly dispatchFn: jest.Mock, + ) { + super(payoutEvmService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected dispatchPayout(order: PayoutOrder): Promise { + return this.dispatchFn(order); + } + + protected getCurrentGasForTransaction(): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-retry.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-retry.spec.ts new file mode 100644 index 0000000000..a7c93c5dc2 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-retry.spec.ts @@ -0,0 +1,346 @@ +import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import * as processServiceModule from 'src/shared/services/process.service'; +import { PayoutTxStatus } from 'src/subdomains/supporting/payout/interfaces'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../exceptions/payout-broadcast.exception'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { EvmStrategy } from '../impl/base/evm.strategy'; + +describe('Payout EVM retry x designate-before-broadcast guard', () => { + describe('EvmStrategy #checkPayoutCompletionData(...)', () => { + let strategy: EvmStrategyWrapper; + let payoutEvmService: PayoutEvmService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let dispatchFn: jest.Mock; + let repoSaveSpy: jest.SpyInstance; + let convertFn: jest.Mock; + + beforeEach(() => { + new ConfigService(); // sets the module-level Config (Config.defaultVolumeDecimal used by recordPayoutFee) + payoutEvmService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + dispatchFn = jest.fn(); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + + // TX_SPEEDUP is fail-closed (disabled) by default in tests; enable it so nonce reuse/fresh-nonce + // behaviour of getOrderNonce(...) is actually observable. + jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + + strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, dispatchFn); + // pricingService is an @Inject() readonly property on PayoutStrategy; there is no DI in the + // unit test, so define it directly on the instance. + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + convertFn = jest.fn().mockReturnValue(42); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('(a) complete: records the payout fee, completes the order and persists once', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OK' }); + const status: PayoutTxStatus = { state: 'complete', fee: 0.001 }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutEvmService.getPayoutCompletionData).toHaveBeenCalledWith('TX_OK'); + expect(pricingService.getPrice).toHaveBeenCalledWith(expect.any(Asset), PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.001, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledTimes(1); + expect(recordFeeSpy).toHaveBeenCalledWith(expect.any(Asset), 0.001, 42); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(designateSpy).not.toHaveBeenCalled(); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('(b) failed, not out-of-gas (on-chain revert): designates for investigation, never retries', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_REVERT' }); + const status: PayoutTxStatus = { state: 'failed', isOutOfGas: false }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + const completeSpy = jest.spyOn(order, 'complete'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + + await strategy.checkPayoutCompletionData([order]); + + expect(designateSpy).toHaveBeenCalledTimes(1); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBe('TX_REVERT'); // txId kept for investigation, not cleared + expect(completeSpy).not.toHaveBeenCalled(); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('(c) failed, out-of-gas + retryable: rolls back, re-designates and dispatches with a fresh nonce', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OOG' }); + const status: PayoutTxStatus = { state: 'failed', isOutOfGas: true }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const completeSpy = jest.spyOn(order, 'complete'); + dispatchFn.mockResolvedValue('TX_NEW_OOG'); + + await strategy.checkPayoutCompletionData([order]); + + // OOG frees the spent nonce: rollback clears payoutTxId, so the guard sees a fresh order and re-designates. + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(designateSpy).toHaveBeenCalledTimes(1); + expect(completeSpy).not.toHaveBeenCalled(); + expect(dispatchFn).toHaveBeenCalledTimes(1); + expect(dispatchFn).toHaveBeenCalledWith(order, undefined); // fresh (unset) nonce, no reuse + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); // fresh nonce, no reuse + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_NEW_OOG'); + expect(repoSaveSpy).toHaveBeenCalledTimes(3); // rollback save + designate save + pending save + }); + + it('(d) expired in mempool + retryable: keeps payoutTxId, skips re-designation and reuses the nonce', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_OLD' }); + order.updated = new Date(Date.now() - 2 * 60 * 60 * 1000); // > 1h ago, so the retry cooldown has elapsed + const status: PayoutTxStatus = { state: 'pending' }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + jest.spyOn(payoutEvmService, 'isTxExpired').mockResolvedValue(true); + jest.spyOn(payoutEvmService, 'getTxNonce').mockResolvedValue(7); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const completeSpy = jest.spyOn(order, 'complete'); + dispatchFn.mockResolvedValue('TX_NEW_EXPIRED'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutEvmService.isTxExpired).toHaveBeenCalledWith('TX_OLD'); + // Guard: payoutTxId was never cleared, so designateBeforeBroadcast skips re-designation. + expect(designateSpy).not.toHaveBeenCalled(); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutEvmService.getTxNonce).toHaveBeenCalledWith('TX_OLD'); // nonce reuse + expect(dispatchFn).toHaveBeenCalledTimes(1); + expect(dispatchFn).toHaveBeenCalledWith(order, 7); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_NEW_EXPIRED'); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the final pending save, no pre-broadcast designate save + }); + + it('(e) pending: no status change, no completion, no designation, no rollback, no dispatch', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_PENDING' }); + order.updated = new Date(); // < 1h ago: retry cooldown has not elapsed + const status: PayoutTxStatus = { state: 'pending' }; + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockResolvedValue(status); + const completeSpy = jest.spyOn(order, 'complete'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + const rollbackSpy = jest.spyOn(order, 'rollbackPayout'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(designateSpy).not.toHaveBeenCalled(); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(dispatchFn).not.toHaveBeenCalled(); + expect(payoutEvmService.isTxExpired).not.toHaveBeenCalled(); // cooldown short-circuits before the chain check + expect(payoutEvmService.getTxNonce).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_PENDING'); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + + describe('EvmStrategy #canRetryFailedPayout(...)', () => { + let strategy: EvmStrategyWrapper; + + beforeEach(() => { + const payoutEvmService = mock(); + const payoutOrderRepo = mock(); + strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, jest.fn()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns false when the order has no payoutTxId (nothing broadcast yet, nothing to retry)', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: null }); + + await expect(strategy.canRetryFailedPayout(order)).resolves.toBe(false); + }); + }); + + describe('EvmStrategy #doPayout(...) — pre-broadcast vs. broadcast-boundary catch', () => { + let strategy: EvmStrategyWrapper; + let payoutOrderRepo: PayoutOrderRepository; + let dispatchFn: jest.Mock; + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + new ConfigService(); // sets Config.payout.maxPreBroadcastRetries (default 3) + const payoutEvmService = mock(); + payoutOrderRepo = mock(); + dispatchFn = jest.fn(); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + + strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, dispatchFn); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('pre-broadcast error on first attempt: rolls back to PREPARATION_CONFIRMED, tracks the failure, no second broadcast', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchFn.mockRejectedValue(new Error('nonce could not be fetched')); + + await strategy.doPayout([order]); + + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.retryCount).toBe(1); + expect(order.lastError).toBe('nonce could not be fetched'); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(dispatchFn).toHaveBeenCalledTimes(1); // no second broadcast in the same run + expect(repoSaveSpy).toHaveBeenCalledTimes(2); // pre-broadcast designate save + rollback save + }); + + it('broadcast-boundary error (PayoutBroadcastException): stays PAYOUT_DESIGNATED, no rollback, no retryCount increment', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchFn.mockRejectedValue(new PayoutBroadcastException('tx may already be in-flight')); + + await strategy.doPayout([order]); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.retryCount).toBe(0); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the pre-broadcast designate save + }); + + it('re-entry (payoutTxId already set) + plain error: never rolls back, preserving nonce reuse', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_DESIGNATED, + payoutTxId: 'OLD_TX', + retryCount: 0, + }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + const designateSpy = jest.spyOn(order, 'designatePayout'); + dispatchFn.mockRejectedValue(new Error('replacement transaction underpriced')); + + await strategy.doPayout([order]); + + expect(designateSpy).not.toHaveBeenCalled(); // re-entry: designateBeforeBroadcast is a no-op + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBe('OLD_TX'); + expect(order.retryCount).toBe(0); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('pre-broadcast error that is not an Error instance: records String(e) as the failure message', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + const recordFailureSpy = jest.spyOn(order, 'recordPayoutFailure'); + dispatchFn.mockRejectedValue('rpc failure string'); // rejection with a non-Error value + + await strategy.doPayout([order]); + + expect(recordFailureSpy).toHaveBeenCalledWith('rpc failure string'); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.retryCount).toBe(1); + expect(order.lastError).toBe('rpc failure string'); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(2); // pre-broadcast designate save + rollback save + }); + + it('pre-broadcast error at the retry cap: stops rolling back so the order escalates to PAYOUT_UNCERTAIN', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + retryCount: Config.payout.maxPreBroadcastRetries, + }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + dispatchFn.mockRejectedValue(new Error('gas estimation failed')); + + await strategy.doPayout([order]); + + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); // left for processFailedOrders -> PAYOUT_UNCERTAIN + expect(order.retryCount).toBe(Config.payout.maxPreBroadcastRetries); // not incremented further + expect(repoSaveSpy).toHaveBeenCalledTimes(1); // only the pre-broadcast designate save + }); + + it('successful payout resets a previously tracked retry count', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + retryCount: 2, + }); + order.lastError = 'gas estimation failed'; + const resetSpy = jest.spyOn(order, 'resetPayoutRetry'); + dispatchFn.mockResolvedValue('TX_OK'); + + await strategy.doPayout([order]); + + expect(resetSpy).toHaveBeenCalledTimes(1); + expect(order.retryCount).toBe(0); + expect(order.lastError).toBeNull(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_OK'); + }); + }); +}); + +class EvmStrategyWrapper extends EvmStrategy { + constructor( + payoutEvmService: PayoutEvmService, + payoutOrderRepo: PayoutOrderRepository, + private readonly dispatchFn: jest.Mock, + ) { + super(payoutEvmService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(order: PayoutOrder): Promise { + // Mirrors the real EVM subclasses (e.g. EthereumCoinStrategy#dispatchPayout), which fetch the + // order nonce right before broadcasting - this is the interaction under test. + const nonce = await this.getOrderNonce(order); + + return this.dispatchFn(order, nonce); + } + + protected getCurrentGasForTransaction(): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(createDefaultAsset()); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-strategies.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-strategies.spec.ts new file mode 100644 index 0000000000..e37ca6cfba --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-evm-strategies.spec.ts @@ -0,0 +1,295 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { ArbitrumCoinStrategy } from '../impl/arbitrum-coin.strategy'; +import { ArbitrumTokenStrategy } from '../impl/arbitrum-token.strategy'; +import { EvmStrategy } from '../impl/base/evm.strategy'; +import { BaseCoinStrategy } from '../impl/base-coin.strategy'; +import { BaseTokenStrategy } from '../impl/base-token.strategy'; +import { BscCoinStrategy } from '../impl/bsc-coin.strategy'; +import { BscTokenStrategy } from '../impl/bsc-token.strategy'; +import { CitreaCoinStrategy } from '../impl/citrea-coin.strategy'; +import { CitreaTokenStrategy } from '../impl/citrea-token.strategy'; +import { EthereumCoinStrategy } from '../impl/ethereum-coin.strategy'; +import { EthereumTokenStrategy } from '../impl/ethereum-token.strategy'; +import { GnosisCoinStrategy } from '../impl/gnosis-coin.strategy'; +import { GnosisTokenStrategy } from '../impl/gnosis-token.strategy'; +import { OptimismCoinStrategy } from '../impl/optimism-coin.strategy'; +import { OptimismTokenStrategy } from '../impl/optimism-token.strategy'; +import { PolygonCoinStrategy } from '../impl/polygon-coin.strategy'; +import { PolygonTokenStrategy } from '../impl/polygon-token.strategy'; +import { SepoliaCoinStrategy } from '../impl/sepolia-coin.strategy'; +import { SepoliaTokenStrategy } from '../impl/sepolia-token.strategy'; + +// The EVM payout services (PayoutArbitrumService, PayoutBscService, ...) differ by static type but +// expose the same broadcast/gas surface used by the strategies; a single generic jest mock covers +// all of them (cast to `any` at the constructor since the concrete type varies per strategy). +interface EvmServiceMock { + sendNativeCoin: jest.Mock; + sendToken: jest.Mock; + getCurrentGasForCoinTransaction: jest.Mock; + getCurrentGasForTokenTransaction: jest.Mock; +} + +function createEvmServiceMock(): EvmServiceMock { + return { + sendNativeCoin: jest.fn(), + sendToken: jest.fn(), + getCurrentGasForCoinTransaction: jest.fn(), + getCurrentGasForTokenTransaction: jest.fn(), + }; +} + +type EvmStrategyCtor = new ( + service: any, + assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, +) => EvmStrategy; + +// Getters that resolve the fee asset on AssetService (assetService.getXCoin()). +type FeeAssetGetter = + | 'getArbitrumCoin' + | 'getBaseCoin' + | 'getBnbCoin' + | 'getCitreaCoin' + | 'getEthCoin' + | 'getGnosisCoin' + | 'getOptimismCoin' + | 'getPolygonCoin' + | 'getSepoliaCoin'; + +interface EvmStrategyCase { + name: string; + Strategy: EvmStrategyCtor; + blockchain: Blockchain; + assetType: AssetType; + isToken: boolean; + feeAssetGetter: FeeAssetGetter; +} + +const cases: EvmStrategyCase[] = [ + { + name: 'ArbitrumCoinStrategy', + Strategy: ArbitrumCoinStrategy, + blockchain: Blockchain.ARBITRUM, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getArbitrumCoin', + }, + { + name: 'ArbitrumTokenStrategy', + Strategy: ArbitrumTokenStrategy, + blockchain: Blockchain.ARBITRUM, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getArbitrumCoin', + }, + { + name: 'BaseCoinStrategy', + Strategy: BaseCoinStrategy, + blockchain: Blockchain.BASE, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getBaseCoin', + }, + { + name: 'BaseTokenStrategy', + Strategy: BaseTokenStrategy, + blockchain: Blockchain.BASE, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getBaseCoin', + }, + { + name: 'BscCoinStrategy', + Strategy: BscCoinStrategy, + blockchain: Blockchain.BINANCE_SMART_CHAIN, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getBnbCoin', + }, + { + name: 'BscTokenStrategy', + Strategy: BscTokenStrategy, + blockchain: Blockchain.BINANCE_SMART_CHAIN, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getBnbCoin', + }, + { + name: 'CitreaCoinStrategy', + Strategy: CitreaCoinStrategy, + blockchain: Blockchain.CITREA, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getCitreaCoin', + }, + { + name: 'CitreaTokenStrategy', + Strategy: CitreaTokenStrategy, + blockchain: Blockchain.CITREA, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getCitreaCoin', + }, + { + name: 'EthereumCoinStrategy', + Strategy: EthereumCoinStrategy, + blockchain: Blockchain.ETHEREUM, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getEthCoin', + }, + { + name: 'EthereumTokenStrategy', + Strategy: EthereumTokenStrategy, + blockchain: Blockchain.ETHEREUM, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getEthCoin', + }, + { + name: 'GnosisCoinStrategy', + Strategy: GnosisCoinStrategy, + blockchain: Blockchain.GNOSIS, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getGnosisCoin', + }, + { + name: 'GnosisTokenStrategy', + Strategy: GnosisTokenStrategy, + blockchain: Blockchain.GNOSIS, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getGnosisCoin', + }, + { + name: 'OptimismCoinStrategy', + Strategy: OptimismCoinStrategy, + blockchain: Blockchain.OPTIMISM, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getOptimismCoin', + }, + { + name: 'OptimismTokenStrategy', + Strategy: OptimismTokenStrategy, + blockchain: Blockchain.OPTIMISM, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getOptimismCoin', + }, + { + name: 'PolygonCoinStrategy', + Strategy: PolygonCoinStrategy, + blockchain: Blockchain.POLYGON, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getPolygonCoin', + }, + { + name: 'PolygonTokenStrategy', + Strategy: PolygonTokenStrategy, + blockchain: Blockchain.POLYGON, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getPolygonCoin', + }, + { + name: 'SepoliaCoinStrategy', + Strategy: SepoliaCoinStrategy, + blockchain: Blockchain.SEPOLIA, + assetType: AssetType.COIN, + isToken: false, + feeAssetGetter: 'getSepoliaCoin', + }, + { + name: 'SepoliaTokenStrategy', + Strategy: SepoliaTokenStrategy, + blockchain: Blockchain.SEPOLIA, + assetType: AssetType.TOKEN, + isToken: true, + feeAssetGetter: 'getSepoliaCoin', + }, +]; + +describe('Payout EVM strategies (table-driven)', () => { + describe.each(cases)('$name', ({ Strategy, blockchain, assetType, isToken, feeAssetGetter }) => { + let service: EvmServiceMock; + let assetService: AssetService; + let payoutOrderRepo: PayoutOrderRepository; + let strategy: EvmStrategy; + + beforeEach(() => { + service = createEvmServiceMock(); + assetService = mock(); + payoutOrderRepo = mock(); + strategy = new Strategy(service as any, assetService, payoutOrderRepo); + }); + + it('exposes the expected blockchain', () => { + expect(strategy.blockchain).toBe(blockchain); + }); + + it(`exposes assetType ${isToken ? 'TOKEN' : 'COIN'}`, () => { + expect(strategy.assetType).toBe(assetType); + }); + + it('dispatchPayout broadcasts via the correct service method with a fresh (undefined) nonce', async () => { + // No payoutTxId → getOrderNonce short-circuits to undefined (no nonce reuse), so the send is + // called with an undefined nonce. + const order = createCustomPayoutOrder({ + payoutTxId: null, + destinationAddress: 'DEST_ADDR', + amount: 1.23, + asset: createCustomAsset({ dexName: 'PAYOUT_TOKEN' }), + }); + const send = isToken ? service.sendToken : service.sendNativeCoin; + send.mockResolvedValue('TX_BROADCASTED'); + + const result = await (strategy as any).dispatchPayout(order); + + expect(result).toBe('TX_BROADCASTED'); + if (isToken) { + expect(service.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount, undefined); + expect(service.sendNativeCoin).not.toHaveBeenCalled(); + } else { + expect(service.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount, undefined); + expect(service.sendToken).not.toHaveBeenCalled(); + } + }); + + it('getCurrentGasForTransaction delegates to the correct gas estimation method', async () => { + const token: Asset = createCustomAsset({ dexName: 'GAS_TOKEN' }); + const gas = isToken ? service.getCurrentGasForTokenTransaction : service.getCurrentGasForCoinTransaction; + gas.mockResolvedValue(0.00042); + + const result = await (strategy as any).getCurrentGasForTransaction(token); + + expect(result).toBe(0.00042); + if (isToken) { + expect(service.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(token); + expect(service.getCurrentGasForCoinTransaction).not.toHaveBeenCalled(); + } else { + expect(service.getCurrentGasForCoinTransaction).toHaveBeenCalledWith(); + expect(service.getCurrentGasForTokenTransaction).not.toHaveBeenCalled(); + } + }); + + it(`getFeeAsset resolves the fee asset via assetService.${feeAssetGetter}()`, async () => { + const feeAsset: Asset = createCustomAsset({ dexName: 'FEE_ASSET' }); + const getter = assetService[feeAssetGetter] as jest.Mock; + getter.mockResolvedValue(feeAsset); + + const result = await (strategy as any).getFeeAsset(); + + expect(result).toBe(feeAsset); + expect(getter).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-nonevm-strategies.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-nonevm-strategies.spec.ts new file mode 100644 index 0000000000..fc32ca84b3 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-nonevm-strategies.spec.ts @@ -0,0 +1,967 @@ +import { mock } from 'jest-mock-extended'; +import { ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { FeeResult } from '../../../interfaces'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutGroup } from '../../../services/base/payout-bitcoin-based.service'; +import { PayoutBitcoinTestnet4Service } from '../../../services/payout-bitcoin-testnet4.service'; +import { PayoutBitcoinService } from '../../../services/payout-bitcoin.service'; +import { PayoutCardanoService } from '../../../services/payout-cardano.service'; +import { PayoutFiroService } from '../../../services/payout-firo.service'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { PayoutMoneroService } from '../../../services/payout-monero.service'; +import { PayoutSolanaService } from '../../../services/payout-solana.service'; +import { PayoutTronService } from '../../../services/payout-tron.service'; +import { PayoutZanoService } from '../../../services/payout-zano.service'; +import { BitcoinTestnet4Strategy } from '../impl/bitcoin-testnet4.strategy'; +import { BitcoinStrategy } from '../impl/bitcoin.strategy'; +import { PayoutStrategy } from '../impl/base/payout.strategy'; +import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; +import { CardanoTokenStrategy } from '../impl/cardano-token.strategy'; +import { FiroStrategy } from '../impl/firo.strategy'; +import { InternetComputerCoinStrategy } from '../impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy } from '../impl/icp-token.strategy'; +import { MoneroStrategy } from '../impl/monero.strategy'; +import { SolanaCoinStrategy } from '../impl/solana-coin.strategy'; +import { SolanaTokenStrategy } from '../impl/solana-token.strategy'; +import { TronCoinStrategy } from '../impl/tron-coin.strategy'; +import { TronTokenStrategy } from '../impl/tron-token.strategy'; +import { ZanoCoinStrategy } from '../impl/zano-coin.strategy'; +import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; + +// Leaf-strategy coverage for the concrete non-EVM payout strategies. The registry spec only +// constructs these classes (hard-coding blockchain/assetType in its wrapper), so their own +// getters plus the leaf overrides (getFeeAsset, getCurrentGasForTransaction, dispatchPayout, +// estimateFee, doPayoutForContext, hasEnoughUnlockedBalance) are otherwise never executed. +// The shared base classes in impl/base/ are covered separately. Arkade/Spark/Lightning are +// already fully covered by payout-strategy-fee-completion.leaf.spec.ts and +// payout-designate-before-broadcast.strategy.spec.ts, so they are intentionally not repeated here. + +const CONTEXT = PayoutOrderContext.BUY_CRYPTO; + +// --------------------------------------------------------------------------------------------- +// Cardano / Solana / Tron / ICP coin & token leaves. These only add getters, getFeeAsset, +// getCurrentGasForTransaction and dispatchPayout on top of a shared base; each is exercised via +// its public entry point (getters directly, getFeeAsset via feeAsset(), getCurrentGasForTransaction +// via estimateFee(), dispatchPayout via doPayout()). +// --------------------------------------------------------------------------------------------- + +interface LeafSetup { + strategy: PayoutStrategy; + feeAsset: Asset; + gasAsset: Asset; + mockFeeAsset: () => void; + expectFeeAssetResolved: () => void; + mockGas: (gas: number) => void; + expectGasResolved: () => void; + mockDispatch: (txId: string) => void; + expectDispatched: (order: PayoutOrder) => void; + estimate: (asset: Asset) => Promise; + doPayout: (orders: PayoutOrder[]) => Promise; + makeOrder: () => PayoutOrder; +} + +interface LeafConfig { + name: string; + blockchain: Blockchain; + assetType: AssetType; + // ICP's base estimateFee echoes the passed asset; the others resolve the chain fee asset. + estimateReturnsFeeAsset: boolean; + setup: () => LeafSetup; +} + +function repoSaveEcho(payoutOrderRepo: PayoutOrderRepository): void { + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); +} + +function makeConfirmedCoinOrder(): PayoutOrder { + return createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + destinationAddress: 'DEST_ADDR', + amount: 5, + }); +} + +function makeConfirmedTokenOrder(tokenAsset: Asset): PayoutOrder { + return createCustomPayoutOrder({ + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + destinationAddress: 'DEST_ADDR', + amount: 5, + asset: tokenAsset, + }); +} + +function runNonEvmLeafSuite(config: LeafConfig): void { + describe(config.name, () => { + it(`exposes the ${config.blockchain} blockchain and the ${config.assetType} asset type`, () => { + const s = config.setup(); + + expect(s.strategy.blockchain).toBe(config.blockchain); + expect(s.strategy.assetType).toBe(config.assetType); + }); + + it('feeAsset() delegates to the AssetService fee-asset getter', async () => { + const s = config.setup(); + s.mockFeeAsset(); + + await expect(s.strategy.feeAsset()).resolves.toBe(s.feeAsset); + + s.expectFeeAssetResolved(); + }); + + it('estimateFee(...) delegates the gas lookup to the chain service and returns the fee', async () => { + const s = config.setup(); + s.mockFeeAsset(); + s.mockGas(0.123); + + const result = await s.estimate(s.gasAsset); + + s.expectGasResolved(); + expect(result.amount).toBe(0.123); + expect(result.asset).toBe(config.estimateReturnsFeeAsset ? s.feeAsset : s.gasAsset); + }); + + it('doPayout(...) broadcasts through the chain service and reaches PAYOUT_PENDING', async () => { + const s = config.setup(); + s.mockDispatch('TX_LEAF'); + const order = s.makeOrder(); + + await s.doPayout([order]); + + s.expectDispatched(order); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(order.payoutTxId).toBe('TX_LEAF'); + }); + }); +} + +describe('Payout non-EVM leaf strategies', () => { + beforeAll(() => { + new ConfigService(); // sets the module-level Config (Config.blockchain.firo used by FiroStrategy#estimateFee) + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ------------------------------- Cardano ------------------------------- + + runNonEvmLeafSuite({ + name: 'CardanoCoinStrategy', + blockchain: Blockchain.CARDANO, + assetType: AssetType.COIN, + estimateReturnsFeeAsset: true, + setup: () => { + const cardanoService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'ADA' }); + const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new CardanoCoinStrategy(cardanoService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getCardanoCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getCardanoCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(cardanoService, 'getCurrentGasForCoinTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(cardanoService.getCurrentGasForCoinTransaction).toHaveBeenCalledTimes(1), + mockDispatch: (tx) => jest.spyOn(cardanoService, 'sendNativeCoin').mockResolvedValue(tx), + expectDispatched: (order) => + expect(cardanoService.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: makeConfirmedCoinOrder, + }; + }, + }); + + runNonEvmLeafSuite({ + name: 'CardanoTokenStrategy', + blockchain: Blockchain.CARDANO, + assetType: AssetType.TOKEN, + estimateReturnsFeeAsset: true, + setup: () => { + const cardanoService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'ADA' }); + const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new CardanoTokenStrategy(cardanoService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getCardanoCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getCardanoCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(cardanoService, 'getCurrentGasForTokenTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(cardanoService.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(gasAsset), + mockDispatch: (tx) => jest.spyOn(cardanoService, 'sendToken').mockResolvedValue(tx), + expectDispatched: (order) => + expect(cardanoService.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: () => makeConfirmedTokenOrder(createCustomAsset({ id: 7, type: AssetType.TOKEN })), + }; + }, + }); + + // ------------------------------- Solana ------------------------------- + + runNonEvmLeafSuite({ + name: 'SolanaCoinStrategy', + blockchain: Blockchain.SOLANA, + assetType: AssetType.COIN, + estimateReturnsFeeAsset: true, + setup: () => { + const solanaService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'SOL' }); + const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new SolanaCoinStrategy(solanaService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getSolanaCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getSolanaCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(solanaService, 'getCurrentGasForCoinTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(solanaService.getCurrentGasForCoinTransaction).toHaveBeenCalledTimes(1), + mockDispatch: (tx) => jest.spyOn(solanaService, 'sendNativeCoin').mockResolvedValue(tx), + expectDispatched: (order) => + expect(solanaService.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: makeConfirmedCoinOrder, + }; + }, + }); + + runNonEvmLeafSuite({ + name: 'SolanaTokenStrategy', + blockchain: Blockchain.SOLANA, + assetType: AssetType.TOKEN, + estimateReturnsFeeAsset: true, + setup: () => { + const solanaService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'SOL' }); + const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new SolanaTokenStrategy(solanaService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getSolanaCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getSolanaCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(solanaService, 'getCurrentGasForTokenTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(solanaService.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(gasAsset), + mockDispatch: (tx) => jest.spyOn(solanaService, 'sendToken').mockResolvedValue(tx), + expectDispatched: (order) => + expect(solanaService.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: () => makeConfirmedTokenOrder(createCustomAsset({ id: 7, type: AssetType.TOKEN })), + }; + }, + }); + + // ------------------------------- Tron ------------------------------- + + runNonEvmLeafSuite({ + name: 'TronCoinStrategy', + blockchain: Blockchain.TRON, + assetType: AssetType.COIN, + estimateReturnsFeeAsset: true, + setup: () => { + const tronService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'TRX' }); + const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new TronCoinStrategy(tronService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getTronCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getTronCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(tronService, 'getCurrentGasForCoinTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(tronService.getCurrentGasForCoinTransaction).toHaveBeenCalledTimes(1), + mockDispatch: (tx) => jest.spyOn(tronService, 'sendNativeCoin').mockResolvedValue(tx), + expectDispatched: (order) => + expect(tronService.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: makeConfirmedCoinOrder, + }; + }, + }); + + runNonEvmLeafSuite({ + name: 'TronTokenStrategy', + blockchain: Blockchain.TRON, + assetType: AssetType.TOKEN, + estimateReturnsFeeAsset: true, + setup: () => { + const tronService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'TRX' }); + const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new TronTokenStrategy(tronService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getTronCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getTronCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(tronService, 'getCurrentGasForTokenTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(tronService.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(gasAsset), + mockDispatch: (tx) => jest.spyOn(tronService, 'sendToken').mockResolvedValue(tx), + expectDispatched: (order) => + expect(tronService.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: () => makeConfirmedTokenOrder(createCustomAsset({ id: 7, type: AssetType.TOKEN })), + }; + }, + }); + + // ------------------------------- ICP ------------------------------- + + runNonEvmLeafSuite({ + name: 'InternetComputerCoinStrategy', + blockchain: Blockchain.INTERNET_COMPUTER, + assetType: AssetType.COIN, + estimateReturnsFeeAsset: false, + setup: () => { + const icpService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'ICP' }); + const gasAsset = createCustomAsset({ id: 42, type: AssetType.COIN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new InternetComputerCoinStrategy(icpService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getInternetComputerCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getInternetComputerCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(icpService, 'getCurrentGasForCoinTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(icpService.getCurrentGasForCoinTransaction).toHaveBeenCalledTimes(1), + mockDispatch: (tx) => jest.spyOn(icpService, 'sendNativeCoin').mockResolvedValue(tx), + expectDispatched: (order) => + expect(icpService.sendNativeCoin).toHaveBeenCalledWith(order.destinationAddress, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: makeConfirmedCoinOrder, + }; + }, + }); + + runNonEvmLeafSuite({ + name: 'InternetComputerTokenStrategy', + blockchain: Blockchain.INTERNET_COMPUTER, + assetType: AssetType.TOKEN, + estimateReturnsFeeAsset: false, + setup: () => { + const icpService = mock(); + const assetService = mock(); + const payoutOrderRepo = mock(); + const feeAsset = createCustomAsset({ name: 'ICP' }); + const gasAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + repoSaveEcho(payoutOrderRepo); + const strategy = new InternetComputerTokenStrategy(icpService, assetService, payoutOrderRepo); + + return { + strategy, + feeAsset, + gasAsset, + mockFeeAsset: () => jest.spyOn(assetService, 'getInternetComputerCoin').mockResolvedValue(feeAsset), + expectFeeAssetResolved: () => expect(assetService.getInternetComputerCoin).toHaveBeenCalledTimes(1), + mockGas: (g) => jest.spyOn(icpService, 'getCurrentGasForTokenTransaction').mockResolvedValue(g), + expectGasResolved: () => expect(icpService.getCurrentGasForTokenTransaction).toHaveBeenCalledWith(gasAsset), + mockDispatch: (tx) => jest.spyOn(icpService, 'sendToken').mockResolvedValue(tx), + expectDispatched: (order) => + expect(icpService.sendToken).toHaveBeenCalledWith(order.destinationAddress, order.asset, order.amount), + estimate: (asset) => strategy.estimateFee(asset), + doPayout: (orders) => strategy.doPayout(orders), + makeOrder: () => makeConfirmedTokenOrder(createCustomAsset({ id: 7, type: AssetType.TOKEN })), + }; + }, + }); + + // ------------------------------- Zano ------------------------------- + + describe('ZanoCoinStrategy', () => { + let notificationService: NotificationService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutZanoService: PayoutZanoService; + let assetService: AssetService; + let strategy: ZanoCoinStrategy; + + beforeEach(() => { + notificationService = mock(); + payoutOrderRepo = mock(); + payoutZanoService = mock(); + assetService = mock(); + strategy = new ZanoCoinStrategy(notificationService, payoutOrderRepo, payoutZanoService, assetService); + }); + + it('exposes the Zano blockchain and a COIN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.ZANO); + expect(strategy.assetType).toBe(AssetType.COIN); + }); + + it('hasEnoughUnlockedBalance(...) returns true when the unlocked coin balance covers the total', async () => { + jest.spyOn(payoutZanoService, 'getUnlockedCoinBalance').mockResolvedValue(10); + const orders = [createCustomPayoutOrder({ amount: 1 }), createCustomPayoutOrder({ amount: 2 })]; + + await expect(strategy.hasEnoughUnlockedBalance(orders)).resolves.toBe(true); + + expect(payoutZanoService.getUnlockedCoinBalance).toHaveBeenCalledTimes(1); + }); + + it('hasEnoughUnlockedBalance(...) returns false when the unlocked coin balance is short', async () => { + jest.spyOn(payoutZanoService, 'getUnlockedCoinBalance').mockResolvedValue(2); + const orders = [createCustomPayoutOrder({ amount: 1 }), createCustomPayoutOrder({ amount: 2 })]; + + await expect(strategy.hasEnoughUnlockedBalance(orders)).resolves.toBe(false); + }); + + it('dispatchPayout(...) delegates the group to PayoutZanoService#sendCoins(...)', async () => { + jest.spyOn(payoutZanoService, 'sendCoins').mockResolvedValue('ZANO_COIN_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayout(CONTEXT, payout)).resolves.toBe('ZANO_COIN_TX'); + + expect(payoutZanoService.sendCoins).toHaveBeenCalledWith(payout); + }); + }); + + describe('ZanoTokenStrategy', () => { + let notificationService: NotificationService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutZanoService: PayoutZanoService; + let assetService: AssetService; + let strategy: ZanoTokenStrategy; + + beforeEach(() => { + notificationService = mock(); + payoutOrderRepo = mock(); + payoutZanoService = mock(); + assetService = mock(); + strategy = new ZanoTokenStrategy(notificationService, payoutOrderRepo, payoutZanoService, assetService); + }); + + it('exposes the Zano blockchain and a TOKEN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.ZANO); + expect(strategy.assetType).toBe(AssetType.TOKEN); + }); + + it('hasEnoughUnlockedBalance(...) checks the unlocked token balance for the order asset', async () => { + const tokenAsset = createCustomAsset({ id: 7, type: AssetType.TOKEN }); + jest.spyOn(payoutZanoService, 'getUnlockedTokenBalance').mockResolvedValue(10); + const orders = [ + createCustomPayoutOrder({ amount: 1, asset: tokenAsset }), + createCustomPayoutOrder({ amount: 2, asset: tokenAsset }), + ]; + + await expect(strategy.hasEnoughUnlockedBalance(orders)).resolves.toBe(true); + + expect(payoutZanoService.getUnlockedTokenBalance).toHaveBeenCalledWith(tokenAsset); + }); + + it('hasEnoughUnlockedBalance(...) returns false when the unlocked token balance is short', async () => { + const tokenAsset = createCustomAsset({ id: 7, type: AssetType.TOKEN }); + jest.spyOn(payoutZanoService, 'getUnlockedTokenBalance').mockResolvedValue(2); + const orders = [ + createCustomPayoutOrder({ amount: 1, asset: tokenAsset }), + createCustomPayoutOrder({ amount: 2, asset: tokenAsset }), + ]; + + await expect(strategy.hasEnoughUnlockedBalance(orders)).resolves.toBe(false); + }); + + it('dispatchPayout(...) delegates the group and token to PayoutZanoService#sendTokens(...)', async () => { + const tokenAsset = createCustomAsset({ id: 7, type: AssetType.TOKEN }); + jest.spyOn(payoutZanoService, 'sendTokens').mockResolvedValue('ZANO_TOKEN_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayout(CONTEXT, payout, tokenAsset)).resolves.toBe('ZANO_TOKEN_TX'); + + expect(payoutZanoService.sendTokens).toHaveBeenCalledWith(payout, tokenAsset); + }); + }); + + // ------------------------------- Bitcoin ------------------------------- + + describe('BitcoinStrategy', () => { + let notificationService: NotificationService; + let bitcoinService: PayoutBitcoinService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let strategy: BitcoinStrategyWrapper; + + beforeEach(() => { + notificationService = mock(); + bitcoinService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + strategy = new BitcoinStrategyWrapper(notificationService, bitcoinService, payoutOrderRepo, assetService); + }); + + it('exposes the Bitcoin blockchain and an undefined asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.BITCOIN); + expect(strategy.assetType).toBeUndefined(); + }); + + it('estimateFee() derives the BTC fee from the current fee rate and the average tx size', async () => { + const feeAsset = createCustomAsset({ name: 'BTC' }); + jest.spyOn(bitcoinService, 'getCurrentFeeRate').mockResolvedValue(100); + jest.spyOn(assetService, 'getBtcCoin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(); + + expect(bitcoinService.getCurrentFeeRate).toHaveBeenCalledTimes(1); + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.00014, 8); // 140 vBytes * 100 / 1e8 + }); + + it('feeAsset() delegates to AssetService#getBtcCoin()', async () => { + const feeAsset = createCustomAsset({ name: 'BTC' }); + jest.spyOn(assetService, 'getBtcCoin').mockResolvedValue(feeAsset); + + await expect(strategy.feeAsset()).resolves.toBe(feeAsset); + + expect(assetService.getBtcCoin).toHaveBeenCalledTimes(1); + }); + + it('dispatchPayout(...) delegates to PayoutBitcoinService#sendUtxoToMany(...)', async () => { + jest.spyOn(bitcoinService, 'sendUtxoToMany').mockResolvedValue('BTC_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('BTC_TX'); + + expect(bitcoinService.sendUtxoToMany).toHaveBeenCalledWith(CONTEXT, payout); + }); + + it('doPayoutForContext(...) skips empty groups and sends the non-empty ones', async () => { + const order = createCustomPayoutOrder({}); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[], [order]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [order]); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith(CONTEXT, [order]); + }); + + it('doPayoutForContext(...) swallows a failing group without propagating the error', async () => { + const order = createCustomPayoutOrder({}); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[order]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockRejectedValue(new Error('btc send failed')); + + await expect(strategy.doPayoutForContextWrapper(CONTEXT, [order])).resolves.toBeUndefined(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------- Bitcoin Testnet4 ------------------------------- + + describe('BitcoinTestnet4Strategy', () => { + let notificationService: NotificationService; + let bitcoinTestnet4Service: PayoutBitcoinTestnet4Service; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let strategy: BitcoinTestnet4StrategyWrapper; + + beforeEach(() => { + notificationService = mock(); + bitcoinTestnet4Service = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + strategy = new BitcoinTestnet4StrategyWrapper( + notificationService, + bitcoinTestnet4Service, + payoutOrderRepo, + assetService, + ); + }); + + it('exposes the Bitcoin Testnet4 blockchain and an undefined asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.BITCOIN_TESTNET4); + expect(strategy.assetType).toBeUndefined(); + }); + + it('estimateFee() derives the fee from the current fee rate and the average tx size', async () => { + const feeAsset = createCustomAsset({ name: 'tBTC' }); + jest.spyOn(bitcoinTestnet4Service, 'getCurrentFeeRate').mockResolvedValue(100); + jest.spyOn(assetService, 'getBitcoinTestnet4Coin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(); + + expect(bitcoinTestnet4Service.getCurrentFeeRate).toHaveBeenCalledTimes(1); + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.00014, 8); // 140 vBytes * 100 / 1e8 + }); + + it('feeAsset() delegates to AssetService#getBitcoinTestnet4Coin()', async () => { + const feeAsset = createCustomAsset({ name: 'tBTC' }); + jest.spyOn(assetService, 'getBitcoinTestnet4Coin').mockResolvedValue(feeAsset); + + await expect(strategy.feeAsset()).resolves.toBe(feeAsset); + + expect(assetService.getBitcoinTestnet4Coin).toHaveBeenCalledTimes(1); + }); + + it('dispatchPayout(...) delegates to PayoutBitcoinTestnet4Service#sendUtxoToMany(...)', async () => { + jest.spyOn(bitcoinTestnet4Service, 'sendUtxoToMany').mockResolvedValue('tBTC_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('tBTC_TX'); + + expect(bitcoinTestnet4Service.sendUtxoToMany).toHaveBeenCalledWith(CONTEXT, payout); + }); + + it('doPayoutForContext(...) skips empty groups and sends the non-empty ones', async () => { + const order = createCustomPayoutOrder({}); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[], [order]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [order]); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith(CONTEXT, [order]); + }); + + it('doPayoutForContext(...) swallows a failing group without propagating the error', async () => { + const order = createCustomPayoutOrder({}); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[order]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockRejectedValue(new Error('tbtc send failed')); + + await expect(strategy.doPayoutForContextWrapper(CONTEXT, [order])).resolves.toBeUndefined(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------- Monero ------------------------------- + + describe('MoneroStrategy', () => { + let notificationService: NotificationService; + let payoutMoneroService: PayoutMoneroService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let strategy: MoneroStrategyWrapper; + + beforeEach(() => { + notificationService = mock(); + payoutMoneroService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + strategy = new MoneroStrategyWrapper(notificationService, payoutMoneroService, payoutOrderRepo, assetService); + }); + + it('exposes the Monero blockchain and an undefined asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.MONERO); + expect(strategy.assetType).toBeUndefined(); + }); + + it('estimateFee() derives the XMR fee from the estimated fee rate and the average tx size', async () => { + const feeAsset = createCustomAsset({ name: 'XMR' }); + jest.spyOn(payoutMoneroService, 'getEstimatedFee').mockResolvedValue(2); + jest.spyOn(assetService, 'getMoneroCoin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(); + + expect(payoutMoneroService.getEstimatedFee).toHaveBeenCalledTimes(1); + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBe(3200); // 1600 Bytes * 2 + }); + + it('getFeeAsset() delegates to AssetService#getMoneroCoin()', async () => { + const feeAsset = createCustomAsset({ name: 'XMR' }); + jest.spyOn(assetService, 'getMoneroCoin').mockResolvedValue(feeAsset); + + await expect(strategy.feeAsset()).resolves.toBe(feeAsset); + + expect(assetService.getMoneroCoin).toHaveBeenCalledTimes(1); + }); + + it('dispatchPayout(...) delegates to PayoutMoneroService#sendToMany(...)', async () => { + jest.spyOn(payoutMoneroService, 'sendToMany').mockResolvedValue('XMR_TX'); + const payout: PayoutGroup = [{ addressTo: 'ADDR', amount: 1 }]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('XMR_TX'); + + expect(payoutMoneroService.sendToMany).toHaveBeenCalledWith(CONTEXT, payout); + }); + + it('doPayoutForContext(...) splices groups up to the size cap and pays out until orders are drained', async () => { + const orders = Array.from({ length: 16 }, (_, i) => createCustomPayoutOrder({ id: i + 1, amount: 1 })); + jest.spyOn(payoutMoneroService, 'getUnlockedBalance').mockResolvedValue(100); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, orders); + + expect(sendSpy).toHaveBeenCalledTimes(2); + expect((sendSpy.mock.calls[0][1] as PayoutOrder[]).length).toBe(15); // maxSize cap + expect((sendSpy.mock.calls[1][1] as PayoutOrder[]).length).toBe(1); // remaining order + }); + + it('doPayoutForContext(...) stops when the next order exceeds the unlocked balance', async () => { + const order = createCustomPayoutOrder({ amount: 5 }); + jest.spyOn(payoutMoneroService, 'getUnlockedBalance').mockResolvedValue(3); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [order]); + + expect(sendSpy).not.toHaveBeenCalled(); + expect(payoutMoneroService.getUnlockedBalance).toHaveBeenCalledTimes(1); + }); + + it('doPayoutForContext(...) stops immediately when there is no unlocked balance', async () => { + const order = createCustomPayoutOrder({ amount: 1 }); + jest.spyOn(payoutMoneroService, 'getUnlockedBalance').mockResolvedValue(0); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [order]); + + expect(sendSpy).not.toHaveBeenCalled(); + expect(payoutMoneroService.getUnlockedBalance).toHaveBeenCalledTimes(1); + }); + + it('doPayoutForContext(...) breaks out of the loop when a send throws', async () => { + const order = createCustomPayoutOrder({ amount: 1 }); + jest.spyOn(payoutMoneroService, 'getUnlockedBalance').mockResolvedValue(100); + const sendSpy = jest.spyOn(strategy as any, 'send').mockRejectedValue(new Error('xmr send failed')); + + await expect(strategy.doPayoutForContextWrapper(CONTEXT, [order])).resolves.toBeUndefined(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------- Firo ------------------------------- + + describe('FiroStrategy', () => { + let notificationService: NotificationService; + let firoService: PayoutFiroService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let strategy: FiroStrategyWrapper; + + beforeEach(() => { + notificationService = mock(); + firoService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + strategy = new FiroStrategyWrapper(notificationService, firoService, payoutOrderRepo, assetService); + }); + + it('exposes the Firo blockchain and an undefined asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.FIRO); + expect(strategy.assetType).toBeUndefined(); + }); + + it('estimateFee(...) uses the transparent tx size when no Spark address is given', async () => { + const feeAsset = createCustomAsset({ name: 'FIRO' }); + const isSparkSpy = jest.spyOn(CryptoService, 'isFiroSparkAddress'); + jest.spyOn(firoService, 'getCurrentFeeRate').mockResolvedValue(100); + jest.spyOn(assetService, 'getFiroCoin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(feeAsset); + + expect(isSparkSpy).not.toHaveBeenCalled(); // short-circuited by the missing address + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.000225, 8); // 225 bytes * 100 / 1e8 + }); + + it('estimateFee(...) uses the Spark mint tx size for a Spark address', async () => { + const feeAsset = createCustomAsset({ name: 'FIRO' }); + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(true); + jest.spyOn(firoService, 'getCurrentFeeRate').mockResolvedValue(100); + jest.spyOn(assetService, 'getFiroCoin').mockResolvedValue(feeAsset); + + const result = await strategy.estimateFee(feeAsset, 'SPARK_ADDR'); + + expect(CryptoService.isFiroSparkAddress).toHaveBeenCalledWith('SPARK_ADDR'); + expect(result.asset).toBe(feeAsset); + expect(result.amount).toBeCloseTo(0.00048, 8); // 480 bytes * 100 / 1e8 + }); + + it('getFeeAsset() delegates to AssetService#getFiroCoin()', async () => { + const feeAsset = createCustomAsset({ name: 'FIRO' }); + jest.spyOn(assetService, 'getFiroCoin').mockResolvedValue(feeAsset); + + await expect(strategy.feeAsset()).resolves.toBe(feeAsset); + + expect(assetService.getFiroCoin).toHaveBeenCalledTimes(1); + }); + + it('dispatchPayout(...) sends a transparent group via sendUtxoToMany(...)', async () => { + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(false); + jest.spyOn(firoService, 'sendUtxoToMany').mockResolvedValue('FIRO_TX'); + const payout: PayoutGroup = [{ addressTo: 'T1', amount: 1 }]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('FIRO_TX'); + + expect(firoService.sendUtxoToMany).toHaveBeenCalledWith(CONTEXT, payout); + expect(firoService.mintSpark).not.toHaveBeenCalled(); + }); + + it('dispatchPayout(...) mints an all-Spark group via mintSpark(...)', async () => { + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(true); + jest.spyOn(firoService, 'mintSpark').mockResolvedValue('FIRO_MINT_TX'); + const payout: PayoutGroup = [ + { addressTo: 'S1', amount: 1 }, + { addressTo: 'S2', amount: 2 }, + ]; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('FIRO_MINT_TX'); + + expect(firoService.mintSpark).toHaveBeenCalledWith(payout); + expect(firoService.sendUtxoToMany).not.toHaveBeenCalled(); + }); + + it('dispatchPayout(...) rejects a mixed Spark/transparent group', () => { + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockImplementation((addr) => addr === 'S1'); + const payout: PayoutGroup = [ + { addressTo: 'S1', amount: 1 }, + { addressTo: 'T1', amount: 2 }, + ]; + + // dispatchPayout is a non-async method that throws synchronously on a mixed group (firo.strategy.ts:92), + // so it must be asserted as a synchronous throw rather than a rejected promise. + expect(() => strategy.dispatchPayoutWrapper(CONTEXT, payout)).toThrow( + 'Mixed Spark/transparent payout group detected', + ); + + expect(firoService.mintSpark).not.toHaveBeenCalled(); + expect(firoService.sendUtxoToMany).not.toHaveBeenCalled(); + }); + + it('dispatchPayout(...) treats an empty group as transparent', async () => { + const isSparkSpy = jest.spyOn(CryptoService, 'isFiroSparkAddress'); + jest.spyOn(firoService, 'sendUtxoToMany').mockResolvedValue('FIRO_EMPTY_TX'); + const payout: PayoutGroup = []; + + await expect(strategy.dispatchPayoutWrapper(CONTEXT, payout)).resolves.toBe('FIRO_EMPTY_TX'); + + expect(isSparkSpy).not.toHaveBeenCalled(); // short-circuited by the empty group + expect(firoService.sendUtxoToMany).toHaveBeenCalledWith(CONTEXT, payout); + }); + + it('doPayoutForContext(...) splits orders into Spark and transparent groups and sends both', async () => { + const sparkOrder = createCustomPayoutOrder({ id: 1, destinationAddress: 'SPARK_ADDR' }); + const transparentOrder = createCustomPayoutOrder({ id: 2, destinationAddress: 'TRANS_ADDR' }); + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockImplementation((addr) => addr === 'SPARK_ADDR'); + jest + .spyOn(strategy as any, 'createPayoutGroups') + .mockImplementation((...args: unknown[]) => [[], args[0] as PayoutOrder[]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [sparkOrder, transparentOrder]); + + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy).toHaveBeenNthCalledWith(1, CONTEXT, [sparkOrder]); + expect(sendSpy).toHaveBeenNthCalledWith(2, CONTEXT, [transparentOrder]); + }); + + it('doPayoutForContext(...) skips the transparent branch when every order is Spark', async () => { + const sparkOrder = createCustomPayoutOrder({ id: 1, destinationAddress: 'SPARK_ADDR' }); + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(true); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[sparkOrder]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockResolvedValue(undefined); + + await strategy.doPayoutForContextWrapper(CONTEXT, [sparkOrder]); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith(CONTEXT, [sparkOrder]); + }); + + it('doPayoutForContext(...) swallows a failing transparent group without propagating the error', async () => { + const transparentOrder = createCustomPayoutOrder({ id: 2, destinationAddress: 'TRANS_ADDR' }); + jest.spyOn(CryptoService, 'isFiroSparkAddress').mockReturnValue(false); + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[transparentOrder]]); + const sendSpy = jest.spyOn(strategy as any, 'send').mockRejectedValue(new Error('firo send failed')); + + await expect(strategy.doPayoutForContextWrapper(CONTEXT, [transparentOrder])).resolves.toBeUndefined(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + }); +}); + +// --------------------------------------------------------------------------------------------- +// Wrappers exposing the protected doPayoutForContext / dispatchPayout of the Bitcoin-based leaves +// so they can be driven directly without the full base doPayout() plumbing. +// --------------------------------------------------------------------------------------------- + +class BitcoinStrategyWrapper extends BitcoinStrategy { + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } + + dispatchPayoutWrapper(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.dispatchPayout(context, payout); + } +} + +class BitcoinTestnet4StrategyWrapper extends BitcoinTestnet4Strategy { + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } + + dispatchPayoutWrapper(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.dispatchPayout(context, payout); + } +} + +class MoneroStrategyWrapper extends MoneroStrategy { + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } + + dispatchPayoutWrapper(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.dispatchPayout(context, payout); + } +} + +class FiroStrategyWrapper extends FiroStrategy { + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } + + dispatchPayoutWrapper(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.dispatchPayout(context, payout); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.base.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.base.spec.ts new file mode 100644 index 0000000000..7bfb5a45b9 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.base.spec.ts @@ -0,0 +1,693 @@ +import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutCardanoService } from '../../../services/payout-cardano.service'; +import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { PayoutSolanaService } from '../../../services/payout-solana.service'; +import { PayoutTronService } from '../../../services/payout-tron.service'; +import { CardanoStrategy } from '../impl/base/cardano.strategy'; +import { EvmStrategy } from '../impl/base/evm.strategy'; +import { InternetComputerStrategy } from '../impl/base/icp.strategy'; +import { SolanaStrategy } from '../impl/base/solana.strategy'; +import { TronStrategy } from '../impl/base/tron.strategy'; + +function withPricingService(strategy: any, pricingService: PricingService): void { + // pricingService is an @Inject() readonly property on PayoutStrategy; there is no DI in the + // unit test, so define it directly on the instance. + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); +} + +describe('Payout strategy base classes - estimateFee / estimateBlockchainFee / checkPayoutCompletionData', () => { + beforeEach(() => { + new ConfigService(); // sets the module-level Config (Config.defaultVolumeDecimal used by recordPayoutFee) + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------------------------- + // EvmStrategy: estimateFee / estimateBlockchainFee only (checkPayoutCompletionData is fully + // covered by payout-evm-retry.spec.ts and must not be duplicated here). + // --------------------------------------------------------------------------------------------- + describe('EvmStrategy', () => { + let strategy: EvmStrategyWrapper; + let payoutEvmService: PayoutEvmService; + let payoutOrderRepo: PayoutOrderRepository; + let feeAsset: Asset; + + beforeEach(() => { + payoutEvmService = mock(); + payoutOrderRepo = mock(); + feeAsset = createDefaultAsset(); + strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo, feeAsset); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and caches it per asset id', async () => { + const asset = createCustomAsset({ id: 5 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.002); + + const first: FeeResult = await strategy.estimateFee(asset); + const second: FeeResult = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset: feeAsset, amount: 0.002 }); + expect(second).toEqual({ asset: feeAsset, amount: 0.002 }); + expect(gasSpy).toHaveBeenCalledTimes(1); // AsyncCache: second call served from cache + expect(gasSpy).toHaveBeenCalledWith(asset); + + const otherAsset = createCustomAsset({ id: 55 }); + const third: FeeResult = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: feeAsset, amount: 0.002 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 6 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.003); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset: feeAsset, amount: 0.003 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) catches and logs errors from getPayoutCompletionData(...) without throwing', async () => { + const pricingService = mock(); + withPricingService(strategy, pricingService); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_ERR' }); + jest.spyOn(payoutEvmService, 'getPayoutCompletionData').mockRejectedValue(new Error('rpc unavailable')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(`Error in checking completion of EVM payout order ${order.id}`), + expect.any(Error), + ); + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------------------- + // SolanaStrategy + // --------------------------------------------------------------------------------------------- + describe('SolanaStrategy', () => { + let strategy: SolanaStrategyWrapper; + let solanaService: PayoutSolanaService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let feeAsset: Asset; + let convertFn: jest.Mock; + + beforeEach(() => { + solanaService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + feeAsset = createDefaultAsset(); + strategy = new SolanaStrategyWrapper(solanaService, payoutOrderRepo, feeAsset); + withPricingService(strategy, pricingService); + convertFn = jest.fn().mockReturnValue(42); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and caches it per asset id', async () => { + const asset = createCustomAsset({ id: 11 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.0001); + + const first = await strategy.estimateFee(asset); + const second = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset: feeAsset, amount: 0.0001 }); + expect(second).toEqual({ asset: feeAsset, amount: 0.0001 }); + expect(gasSpy).toHaveBeenCalledTimes(1); + + const otherAsset = createCustomAsset({ id: 111 }); + const third = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: feeAsset, amount: 0.0001 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 12 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.0002); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset: feeAsset, amount: 0.0002 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) completes the order, records the fee and persists once when complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'SOL_TX' }); + jest.spyOn(solanaService, 'getPayoutCompletionData').mockResolvedValue([true, 0.00005]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(solanaService.getPayoutCompletionData).toHaveBeenCalledWith('SOL_TX'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.00005, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.00005, 42); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + }); + + it('checkPayoutCompletionData(...) leaves the order untouched when not yet complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'SOL_TX_2' }); + jest.spyOn(solanaService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + + it('checkPayoutCompletionData(...) catches and logs errors without throwing', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'SOL_TX_ERR' }); + jest.spyOn(solanaService, 'getPayoutCompletionData').mockRejectedValue(new Error('rpc down')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------------------- + // TronStrategy + // --------------------------------------------------------------------------------------------- + describe('TronStrategy', () => { + let strategy: TronStrategyWrapper; + let tronService: PayoutTronService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let feeAsset: Asset; + let convertFn: jest.Mock; + + beforeEach(() => { + tronService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + feeAsset = createDefaultAsset(); + strategy = new TronStrategyWrapper(tronService, payoutOrderRepo, feeAsset); + withPricingService(strategy, pricingService); + convertFn = jest.fn().mockReturnValue(7); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and caches it per asset id', async () => { + const asset = createCustomAsset({ id: 21 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(1.5); + + const first = await strategy.estimateFee(asset); + const second = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset: feeAsset, amount: 1.5 }); + expect(second).toEqual({ asset: feeAsset, amount: 1.5 }); + expect(gasSpy).toHaveBeenCalledTimes(1); + + const otherAsset = createCustomAsset({ id: 121 }); + const third = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: feeAsset, amount: 1.5 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 22 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(2.5); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset: feeAsset, amount: 2.5 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) completes the order, records the fee and persists once when complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TRX_TX' }); + jest.spyOn(tronService, 'getPayoutCompletionData').mockResolvedValue([true, 3.2]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(tronService.getPayoutCompletionData).toHaveBeenCalledWith('TRX_TX'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(3.2, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 3.2, 7); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + }); + + it('checkPayoutCompletionData(...) leaves the order untouched when not yet complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TRX_TX_2' }); + jest.spyOn(tronService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + + it('checkPayoutCompletionData(...) catches and logs errors without throwing', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TRX_TX_ERR' }); + jest.spyOn(tronService, 'getPayoutCompletionData').mockRejectedValue(new Error('node down')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------------------- + // CardanoStrategy + // --------------------------------------------------------------------------------------------- + describe('CardanoStrategy', () => { + let strategy: CardanoStrategyWrapper; + let cardanoService: PayoutCardanoService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let feeAsset: Asset; + let convertFn: jest.Mock; + + beforeEach(() => { + cardanoService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + feeAsset = createDefaultAsset(); + strategy = new CardanoStrategyWrapper(cardanoService, payoutOrderRepo, feeAsset); + withPricingService(strategy, pricingService); + convertFn = jest.fn().mockReturnValue(3); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and caches it per asset id', async () => { + const asset = createCustomAsset({ id: 31 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.17); + + const first = await strategy.estimateFee(asset); + const second = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset: feeAsset, amount: 0.17 }); + expect(second).toEqual({ asset: feeAsset, amount: 0.17 }); + expect(gasSpy).toHaveBeenCalledTimes(1); + + const otherAsset = createCustomAsset({ id: 131 }); + const third = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: feeAsset, amount: 0.17 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 32 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.19); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset: feeAsset, amount: 0.19 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) completes the order, records the fee and persists once when complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'ADA_TX' }); + jest.spyOn(cardanoService, 'getPayoutCompletionData').mockResolvedValue([true, 0.18]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(cardanoService.getPayoutCompletionData).toHaveBeenCalledWith('ADA_TX'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.18, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.18, 3); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + }); + + it('checkPayoutCompletionData(...) leaves the order untouched when not yet complete', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'ADA_TX_2' }); + jest.spyOn(cardanoService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + + it('checkPayoutCompletionData(...) catches and logs errors without throwing', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'ADA_TX_ERR' }); + jest.spyOn(cardanoService, 'getPayoutCompletionData').mockRejectedValue(new Error('node down')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------------------- + // InternetComputerStrategy (ICP): unlike the other chains, estimateFee(...) returns the *input* + // asset (not this.feeAsset()) and checkPayoutCompletionData(...) uses order.asset directly as the + // fee asset and forwards the order's asset to getPayoutCompletionData(...) only when it is a TOKEN. + // --------------------------------------------------------------------------------------------- + describe('InternetComputerStrategy', () => { + let strategy: IcpStrategyWrapper; + let internetComputerService: PayoutInternetComputerService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let convertFn: jest.Mock; + + beforeEach(() => { + internetComputerService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + strategy = new IcpStrategyWrapper(internetComputerService, payoutOrderRepo); + withPricingService(strategy, pricingService); + convertFn = jest.fn().mockReturnValue(9); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('estimateFee(...) resolves the gas via getCurrentGasForTransaction(...) and returns the *input* asset (not feeAsset())', async () => { + const asset = createCustomAsset({ id: 41 }); + const gasSpy = jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.0004); + + const first = await strategy.estimateFee(asset); + const second = await strategy.estimateFee(asset); + + expect(first).toEqual({ asset, amount: 0.0004 }); + expect(second).toEqual({ asset, amount: 0.0004 }); + expect(gasSpy).toHaveBeenCalledTimes(1); // AsyncCache: second call served from cache + + const otherAsset = createCustomAsset({ id: 141 }); + const third = await strategy.estimateFee(otherAsset); + + expect(third).toEqual({ asset: otherAsset, amount: 0.0004 }); + // different asset.id -> separate cache key -> cache miss -> a second real gas lookup + expect(gasSpy).toHaveBeenCalledTimes(2); + expect(gasSpy).toHaveBeenCalledWith(otherAsset); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee(...)', async () => { + const asset = createCustomAsset({ id: 42 }); + jest.spyOn(strategy, 'getCurrentGasForTransactionPublic').mockResolvedValue(0.0006); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(asset); + + expect(result).toEqual({ asset, amount: 0.0006 }); + expect(estimateFeeSpy).toHaveBeenCalledWith(asset); + }); + + it('checkPayoutCompletionData(...) forwards the order asset to getPayoutCompletionData(...) for a TOKEN order and records the fee via order.asset when complete', async () => { + const tokenAsset = createCustomAsset({ id: 43, type: AssetType.TOKEN }); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'ICP_TOKEN_TX', + asset: tokenAsset, + }); + jest.spyOn(internetComputerService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0009]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(internetComputerService.getPayoutCompletionData).toHaveBeenCalledWith('ICP_TOKEN_TX', tokenAsset); + expect(pricingService.getPrice).toHaveBeenCalledWith(tokenAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0009, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(tokenAsset, 0.0009, 9); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + }); + + it('checkPayoutCompletionData(...) passes undefined for a COIN order and still completes when complete', async () => { + const coinAsset = createCustomAsset({ id: 44, type: AssetType.COIN }); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'ICP_COIN_TX', + asset: coinAsset, + }); + jest.spyOn(internetComputerService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0001]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(internetComputerService.getPayoutCompletionData).toHaveBeenCalledWith('ICP_COIN_TX', undefined); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0001, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(coinAsset, 0.0001, 9); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + }); + + it('checkPayoutCompletionData(...) leaves the order untouched when not yet complete', async () => { + const coinAsset = createCustomAsset({ id: 45, type: AssetType.COIN }); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'ICP_COIN_TX_2', + asset: coinAsset, + }); + jest.spyOn(internetComputerService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + + it('checkPayoutCompletionData(...) catches and logs errors without throwing', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'ICP_TX_ERR' }); + jest.spyOn(internetComputerService, 'getPayoutCompletionData').mockRejectedValue(new Error('canister down')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + }); + }); +}); + +class EvmStrategyWrapper extends EvmStrategy { + constructor( + payoutEvmService: PayoutEvmService, + payoutOrderRepo: PayoutOrderRepository, + private readonly feeAssetValue: Asset, + ) { + super(payoutEvmService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + // overridable spy target: `getCurrentGasForTransaction` is protected and cannot be spied on + // directly, so it delegates to this public method instead. + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(this.feeAssetValue); + } +} + +class SolanaStrategyWrapper extends SolanaStrategy { + constructor( + solanaService: PayoutSolanaService, + payoutOrderRepo: PayoutOrderRepository, + private readonly feeAssetValue: Asset, + ) { + super(solanaService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.SOLANA; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(this.feeAssetValue); + } +} + +class TronStrategyWrapper extends TronStrategy { + constructor( + tronService: PayoutTronService, + payoutOrderRepo: PayoutOrderRepository, + private readonly feeAssetValue: Asset, + ) { + super(tronService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.TRON; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(this.feeAssetValue); + } +} + +class CardanoStrategyWrapper extends CardanoStrategy { + constructor( + cardanoService: PayoutCardanoService, + payoutOrderRepo: PayoutOrderRepository, + private readonly feeAssetValue: Asset, + ) { + super(cardanoService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.CARDANO; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + return Promise.resolve(this.feeAssetValue); + } +} + +class IcpStrategyWrapper extends InternetComputerStrategy { + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(token?: Asset): Promise { + return this.getCurrentGasForTransactionPublic(token); + } + + getCurrentGasForTransactionPublic(_token?: Asset): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + throw new Error('Method not implemented.'); // never invoked: ICP uses order.asset/input asset directly + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.leaf.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.leaf.spec.ts new file mode 100644 index 0000000000..dbc91ba752 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-strategy-fee-completion.leaf.spec.ts @@ -0,0 +1,434 @@ +import { NotImplementedException } from '@nestjs/common'; +import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { + PriceCurrency, + PriceValidity, + PricingService, +} from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutArkadeService } from '../../../services/payout-arkade.service'; +import { PayoutLightningService } from '../../../services/payout-lightning.service'; +import { PayoutSparkService } from '../../../services/payout-spark.service'; +import { ArkadeStrategy } from '../impl/arkade.strategy'; +import { LightningStrategy } from '../impl/lightning.strategy'; +import { SparkStrategy } from '../impl/spark.strategy'; + +// Leaf-strategy coverage for the three non-EVM/non-Bitcoin-based strategies that implement +// FeeResult estimation and completion tracking directly (no shared base class): Arkade, Spark +// and Lightning. doPayout/designate-before-broadcast is already covered by +// payout-designate-before-broadcast.strategy.spec.ts - this file targets the remaining +// estimateFee/estimateBlockchainFee, checkPayoutCompletionData and the protected +// getFeeAsset/getCurrentGasForTransaction helpers. + +class ArkadeStrategyWrapper extends ArkadeStrategy { + getFeeAssetWrapper(): Promise { + return this.getFeeAsset(); + } + + getCurrentGasForTransactionWrapper(token: Asset): Promise { + return this.getCurrentGasForTransaction(token); + } +} + +class SparkStrategyWrapper extends SparkStrategy { + getFeeAssetWrapper(): Promise { + return this.getFeeAsset(); + } + + getCurrentGasForTransactionWrapper(token: Asset): Promise { + return this.getCurrentGasForTransaction(token); + } +} + +describe('Payout leaf strategies - estimateFee & completion', () => { + beforeEach(() => { + new ConfigService(); // sets the module-level Config (Config.defaultVolumeDecimal used by recordPayoutFee) + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('ArkadeStrategy', () => { + let arkadeService: PayoutArkadeService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let pricingService: PricingService; + let strategy: ArkadeStrategyWrapper; + let feeAsset: Asset; + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + arkadeService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + pricingService = mock(); + feeAsset = createCustomAsset({ name: 'ARKADE_BTC' }); + jest.spyOn(assetService, 'getArkadeCoin').mockResolvedValue(feeAsset); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as any); + + strategy = new ArkadeStrategyWrapper(arkadeService, payoutOrderRepo, assetService); + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + }); + + it('exposes the Arkade blockchain and a COIN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.ARKADE); + expect(strategy.assetType).toBe(AssetType.COIN); + }); + + it('estimateFee() resolves the fee asset via AssetService and returns a zero amount', async () => { + const result = await strategy.estimateFee(); + + expect(assetService.getArkadeCoin).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee() regardless of the passed asset', async () => { + const someAsset = createCustomAsset({ name: 'IRRELEVANT' }); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(someAsset); + + expect(estimateFeeSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + + it('getFeeAsset() delegates to AssetService#getArkadeCoin()', async () => { + const result = await strategy.getFeeAssetWrapper(); + + expect(assetService.getArkadeCoin).toHaveBeenCalledTimes(1); + expect(result).toBe(feeAsset); + }); + + it('getCurrentGasForTransaction(...) delegates to PayoutArkadeService#getCurrentFeeForTransaction(...)', async () => { + jest.spyOn(arkadeService, 'getCurrentFeeForTransaction').mockResolvedValue(123); + + const result = await strategy.getCurrentGasForTransactionWrapper(feeAsset); + + expect(arkadeService.getCurrentFeeForTransaction).toHaveBeenCalledWith(feeAsset); + expect(result).toBe(123); + }); + + it('getPayoutCompletionData(...) delegates to PayoutArkadeService#getPayoutCompletionData(...)', async () => { + jest.spyOn(arkadeService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0005]); + + const result = await strategy.getPayoutCompletionData('TX_ARKADE'); + + expect(arkadeService.getPayoutCompletionData).toHaveBeenCalledWith('TX_ARKADE'); + expect(result).toEqual([true, 0.0005]); + }); + + describe('#checkPayoutCompletionData(...)', () => { + it('complete: records the payout fee (via pricingService/Config), completes the order and persists once', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_ARK_OK' }); + jest.spyOn(arkadeService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0007]); + const convertFn = jest.fn().mockReturnValue(21); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(arkadeService.getPayoutCompletionData).toHaveBeenCalledWith('TX_ARK_OK'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0007, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.0007, 21); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('not complete: leaves the order untouched and does not persist', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'TX_ARK_PENDING', + }); + jest.spyOn(arkadeService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows errors from getPayoutCompletionData and never persists', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_ARK_ERR' }); + jest.spyOn(arkadeService, 'getPayoutCompletionData').mockRejectedValue(new Error('arkade node unreachable')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('SparkStrategy', () => { + let sparkService: PayoutSparkService; + let payoutOrderRepo: PayoutOrderRepository; + let assetService: AssetService; + let pricingService: PricingService; + let strategy: SparkStrategyWrapper; + let feeAsset: Asset; + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + sparkService = mock(); + payoutOrderRepo = mock(); + assetService = mock(); + pricingService = mock(); + feeAsset = createCustomAsset({ name: 'SPARK_BTC' }); + jest.spyOn(assetService, 'getSparkCoin').mockResolvedValue(feeAsset); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as any); + + strategy = new SparkStrategyWrapper(sparkService, payoutOrderRepo, assetService); + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + }); + + it('exposes the Spark blockchain and a COIN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.SPARK); + expect(strategy.assetType).toBe(AssetType.COIN); + }); + + it('estimateFee() resolves the fee asset via AssetService and returns a zero amount', async () => { + const result = await strategy.estimateFee(); + + expect(assetService.getSparkCoin).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + + it('estimateBlockchainFee(...) delegates to estimateFee() regardless of the passed asset', async () => { + const someAsset = createCustomAsset({ name: 'IRRELEVANT' }); + const estimateFeeSpy = jest.spyOn(strategy, 'estimateFee'); + + const result = await strategy.estimateBlockchainFee(someAsset); + + expect(estimateFeeSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + + it('getFeeAsset() delegates to AssetService#getSparkCoin()', async () => { + const result = await strategy.getFeeAssetWrapper(); + + expect(assetService.getSparkCoin).toHaveBeenCalledTimes(1); + expect(result).toBe(feeAsset); + }); + + it('getCurrentGasForTransaction(...) delegates to PayoutSparkService#getCurrentFeeForTransaction(...)', async () => { + jest.spyOn(sparkService, 'getCurrentFeeForTransaction').mockResolvedValue(456); + + const result = await strategy.getCurrentGasForTransactionWrapper(feeAsset); + + expect(sparkService.getCurrentFeeForTransaction).toHaveBeenCalledWith(feeAsset); + expect(result).toBe(456); + }); + + it('getPayoutCompletionData(...) delegates to PayoutSparkService#getPayoutCompletionData(...)', async () => { + jest.spyOn(sparkService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0003]); + + const result = await strategy.getPayoutCompletionData('TX_SPARK'); + + expect(sparkService.getPayoutCompletionData).toHaveBeenCalledWith('TX_SPARK'); + expect(result).toEqual([true, 0.0003]); + }); + + describe('#checkPayoutCompletionData(...)', () => { + it('complete: records the payout fee (via pricingService/Config), completes the order and persists once', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_SPK_OK' }); + jest.spyOn(sparkService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0002]); + const convertFn = jest.fn().mockReturnValue(13); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(sparkService.getPayoutCompletionData).toHaveBeenCalledWith('TX_SPK_OK'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0002, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.0002, 13); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('not complete: leaves the order untouched and does not persist', async () => { + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'TX_SPK_PENDING', + }); + jest.spyOn(sparkService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows errors from getPayoutCompletionData and never persists', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_SPK_ERR' }); + jest.spyOn(sparkService, 'getPayoutCompletionData').mockRejectedValue(new Error('spark node unreachable')); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('LightningStrategy', () => { + let assetService: AssetService; + let payoutLightningService: PayoutLightningService; + let payoutOrderRepo: PayoutOrderRepository; + let pricingService: PricingService; + let strategy: LightningStrategy; + let feeAsset: Asset; + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + assetService = mock(); + payoutLightningService = mock(); + payoutOrderRepo = mock(); + pricingService = mock(); + feeAsset = createCustomAsset({ name: 'LN_BTC' }); + jest.spyOn(assetService, 'getLightningCoin').mockResolvedValue(feeAsset); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as any); + + strategy = new LightningStrategy(assetService, payoutLightningService, payoutOrderRepo); + Object.defineProperty(strategy, 'pricingService', { value: pricingService, configurable: true }); + }); + + it('exposes the Lightning blockchain (asset type is unset - LN has no on-chain asset distinction)', () => { + expect(strategy.blockchain).toBe(Blockchain.LIGHTNING); + expect(strategy.assetType).toBeUndefined(); + }); + + describe('#estimateBlockchainFee(...)', () => { + it('resolves the fee asset via getFeeAsset() and returns a zero amount', async () => { + const result = await strategy.estimateBlockchainFee(); + + expect(assetService.getLightningCoin).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0 }); + }); + }); + + describe('#getFeeAsset(...)', () => { + it('delegates to AssetService#getLightningCoin()', async () => { + const result = await strategy.getFeeAsset(); + + expect(assetService.getLightningCoin).toHaveBeenCalledTimes(1); + expect(result).toBe(feeAsset); + }); + }); + + describe('#estimateFee(...)', () => { + it('throws NotImplementedException when the target asset differs from the reference asset', async () => { + const targetAsset = createCustomAsset({ id: 1 }); + const refAsset = createCustomAsset({ id: 2 }); + + await expect(strategy.estimateFee(targetAsset, 'lnbc_addr', 1, refAsset)).rejects.toThrow( + NotImplementedException, + ); + expect(payoutLightningService.getEstimatedFee).not.toHaveBeenCalled(); + }); + + it('estimates the fee via PayoutLightningService when target and reference asset match', async () => { + const sameAsset = createCustomAsset({ id: 5 }); + jest.spyOn(payoutLightningService, 'getEstimatedFee').mockResolvedValue(0.00001); + + const result = await strategy.estimateFee(sameAsset, 'lnbc_addr', 1000, sameAsset); + + expect(payoutLightningService.getEstimatedFee).toHaveBeenCalledWith('lnbc_addr', 1000); + expect(result).toEqual({ asset: feeAsset, amount: 0.00001 }); + }); + }); + + describe('#checkPayoutCompletionData(...)', () => { + it('health gate: does nothing when isHealthy() is false', async () => { + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(false); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_LN_SKIP' }); + const completeSpy = jest.spyOn(order, 'complete'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutLightningService.getPayoutCompletionData).not.toHaveBeenCalled(); + expect(completeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('complete: records the payout fee (via pricingService/Config), completes the order and persists once', async () => { + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(payoutLightningService, 'getPayoutCompletionData').mockResolvedValue([true, 0.0000015]); + const convertFn = jest.fn().mockReturnValue(7); + jest.spyOn(pricingService, 'getPrice').mockResolvedValue({ convert: convertFn } as any); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_LN_OK' }); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(payoutLightningService.getPayoutCompletionData).toHaveBeenCalledWith('TX_LN_OK'); + expect(pricingService.getPrice).toHaveBeenCalledWith(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + expect(completeSpy).toHaveBeenCalledTimes(1); + expect(convertFn).toHaveBeenCalledWith(0.0000015, Config.defaultVolumeDecimal); + expect(recordFeeSpy).toHaveBeenCalledWith(feeAsset, 0.0000015, 7); + expect(order.status).toBe(PayoutOrderStatus.COMPLETE); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('not complete: leaves the order untouched and does not persist', async () => { + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); + jest.spyOn(payoutLightningService, 'getPayoutCompletionData').mockResolvedValue([false, 0]); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_PENDING, + payoutTxId: 'TX_LN_PENDING', + }); + const completeSpy = jest.spyOn(order, 'complete'); + const recordFeeSpy = jest.spyOn(order, 'recordPayoutFee'); + + await strategy.checkPayoutCompletionData([order]); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(recordFeeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('swallows errors from getPayoutCompletionData and never persists', async () => { + jest.spyOn(payoutLightningService, 'isHealthy').mockResolvedValue(true); + jest + .spyOn(payoutLightningService, 'getPayoutCompletionData') + .mockRejectedValue(new Error('lightning node unreachable')); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'TX_LN_ERR' }); + const completeSpy = jest.spyOn(order, 'complete'); + + await expect(strategy.checkPayoutCompletionData([order])).resolves.toBeUndefined(); + + expect(completeSpy).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-zano.strategy.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-zano.strategy.spec.ts new file mode 100644 index 0000000000..b1d113daf7 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout-zano.strategy.spec.ts @@ -0,0 +1,163 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutGroup } from '../../../services/base/payout-bitcoin-based.service'; +import { PayoutZanoService } from '../../../services/payout-zano.service'; +import { ZanoStrategy } from '../impl/base/zano.strategy'; + +describe('ZanoStrategy', () => { + let strategy: ZanoStrategyWrapper; + + let notificationService: NotificationService; + let payoutOrderRepo: PayoutOrderRepository; + let payoutZanoService: PayoutZanoService; + let assetService: AssetService; + + let repoSaveSpy: jest.SpyInstance; + + beforeEach(() => { + notificationService = mock(); + payoutOrderRepo = mock(); + payoutZanoService = mock(); + assetService = mock(); + + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + + strategy = new ZanoStrategyWrapper(notificationService, payoutOrderRepo, payoutZanoService, assetService); + }); + + it('exposes the Zano blockchain and a COIN asset type', () => { + expect(strategy.blockchain).toBe(Blockchain.ZANO); + expect(strategy.assetType).toBe(AssetType.COIN); + }); + + describe('#estimateFee(...)', () => { + it('returns the Zano fee asset and the service estimated fee', async () => { + const feeAsset = createCustomAsset({ name: 'ZANO' }); + jest.spyOn(assetService, 'getZanoCoin').mockResolvedValue(feeAsset); + jest.spyOn(payoutZanoService, 'getEstimatedFee').mockReturnValue(0.01); + + const result = await strategy.estimateFee(); + + expect(payoutZanoService.getEstimatedFee).toHaveBeenCalledTimes(1); + expect(assetService.getZanoCoin).toHaveBeenCalledTimes(1); + expect(result).toEqual({ asset: feeAsset, amount: 0.01 }); + }); + }); + + describe('#getFeeAsset(...)', () => { + it('delegates to AssetService#getZanoCoin()', async () => { + const feeAsset = createCustomAsset({ name: 'ZANO' }); + jest.spyOn(assetService, 'getZanoCoin').mockResolvedValue(feeAsset); + + const result = await strategy.getFeeAsset(); + + expect(assetService.getZanoCoin).toHaveBeenCalledTimes(1); + expect(result).toBe(feeAsset); + }); + }); + + describe('#doPayoutForContext(...)', () => { + it('pays out a group with sufficient unlocked balance via send(...)', async () => { + const orders = [ + createCustomPayoutOrder({ + id: 1, + status: PayoutOrderStatus.PREPARATION_CONFIRMED, + payoutTxId: null, + destinationAddress: 'ADDR_01', + amount: 1, + }), + ]; + jest.spyOn(strategy, 'hasEnoughUnlockedBalanceImpl').mockResolvedValue(true); + const dispatchSpy = jest.spyOn(strategy, 'dispatchPayoutImpl').mockResolvedValue('ZANO_TX'); + const loggerVerboseSpy = jest.spyOn((strategy as any).logger, 'verbose'); + + await strategy.doPayoutForContextWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(loggerVerboseSpy).toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledWith( + PayoutOrderContext.BUY_CRYPTO, + [{ addressTo: 'ADDR_01', amount: 1 }], + orders[0].asset, + ); + expect(orders[0].status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + expect(orders[0].payoutTxId).toBe('ZANO_TX'); + expect(repoSaveSpy).toHaveBeenCalled(); + }); + + it('logs and does not send when the unlocked balance is insufficient', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + jest.spyOn(strategy, 'hasEnoughUnlockedBalanceImpl').mockResolvedValue(false); + const dispatchSpy = jest.spyOn(strategy, 'dispatchPayoutImpl'); + const loggerInfoSpy = jest.spyOn((strategy as any).logger, 'info'); + + await strategy.doPayoutForContextWrapper(PayoutOrderContext.BUY_CRYPTO, orders); + + expect(loggerInfoSpy).toHaveBeenCalledWith(expect.stringContaining('Insufficient unlocked balance')); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('skips empty payout groups (defensive guard) and only processes non-empty ones', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + // createPayoutGroups never emits an empty group for real input, so force one to reach the guard. + jest.spyOn(strategy as any, 'createPayoutGroups').mockReturnValue([[], [order]]); + const hasBalanceSpy = jest.spyOn(strategy, 'hasEnoughUnlockedBalanceImpl').mockResolvedValue(false); + + await strategy.doPayoutForContextWrapper(PayoutOrderContext.BUY_CRYPTO, [order]); + + expect(hasBalanceSpy).toHaveBeenCalledTimes(1); + expect(hasBalanceSpy).toHaveBeenCalledWith([order]); + }); + + it('catches an error from a group and continues without throwing', async () => { + const orders = [createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null })]; + jest.spyOn(strategy, 'hasEnoughUnlockedBalanceImpl').mockRejectedValue(new Error('balance rpc down')); + const loggerErrorSpy = jest.spyOn((strategy as any).logger, 'error'); + + await expect(strategy.doPayoutForContextWrapper(PayoutOrderContext.BUY_CRYPTO, orders)).resolves.toBeUndefined(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error in paying out a group'), + expect.any(Error), + ); + }); + }); +}); + +class ZanoStrategyWrapper extends ZanoStrategy { + protected readonly logger = new DfxLogger(ZanoStrategyWrapper); + + get blockchain(): Blockchain { + return Blockchain.ZANO; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + // Overridable per-test spy target for the abstract balance check. + hasEnoughUnlockedBalanceImpl: (orders: PayoutOrder[]) => Promise = () => Promise.resolve(true); + + async hasEnoughUnlockedBalance(orders: PayoutOrder[]): Promise { + return this.hasEnoughUnlockedBalanceImpl(orders); + } + + // Overridable per-test so #send(...) tests can simulate a chain dispatch without a real client. + dispatchPayoutImpl: (context: PayoutOrderContext, payout: PayoutGroup, token?: Asset) => Promise = () => + Promise.resolve('ZANO_TX'); + + async dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup, token?: Asset): Promise { + return this.dispatchPayoutImpl(context, payout, token); + } + + doPayoutForContextWrapper(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + return this.doPayoutForContext(context, orders); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.strategy.base.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.strategy.base.spec.ts new file mode 100644 index 0000000000..408356b8a4 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.strategy.base.spec.ts @@ -0,0 +1,271 @@ +import { mock } from 'jest-mock-extended'; +import { Config, ConfigService } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; +import { createCustomPayoutOrder } from '../../../entities/__mocks__/payout-order.entity.mock'; +import { PayoutOrder, PayoutOrderStatus } from '../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../exceptions/payout-broadcast.exception'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { EvmStrategy } from '../impl/base/evm.strategy'; +import { PayoutStrategy } from '../impl/base/payout.strategy'; +import { PayoutStrategyRegistry } from '../impl/base/payout.strategy-registry'; + +describe('PayoutStrategy (base)', () => { + describe('#designateBeforeBroadcast(...)', () => { + let strategy: TestStrategyWrapper; + let payoutOrderRepo: PayoutOrderRepository; + + beforeEach(() => { + payoutOrderRepo = mock(); + + strategy = new TestStrategyWrapper(); + }); + + it('designates and persists the order when payoutTxId is not yet set', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PREPARATION_CONFIRMED, payoutTxId: null }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + + await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + + expect(designateSpy).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledTimes(1); + expect(payoutOrderRepo.save).toHaveBeenCalledWith(order); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + }); + + it('does not designate nor persist the order when payoutTxId is already set', async () => { + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_PENDING, payoutTxId: 'OLD_TX' }); + const designateSpy = jest.spyOn(order, 'designatePayout'); + + await strategy.callDesignateBeforeBroadcast(order, payoutOrderRepo); + + expect(designateSpy).not.toHaveBeenCalled(); + expect(payoutOrderRepo.save).not.toHaveBeenCalled(); + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + }); + }); + + describe('#handleBroadcastError(...)', () => { + let payoutOrderRepo: PayoutOrderRepository; + let repoSaveSpy: jest.SpyInstance; + + beforeAll(() => { + new ConfigService(); // sets module-level Config (Config.payout.maxPreBroadcastRetries) + }); + + beforeEach(() => { + payoutOrderRepo = mock(); + repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save').mockImplementation(async (o) => o as PayoutOrder); + }); + + it('rolls back to PREPARATION_CONFIRMED on a first-attempt plain error (provably pre-broadcast)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + + await strategy.callHandleBroadcastError(order, new Error('gas estimation failed'), payoutOrderRepo); + + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + expect(order.retryCount).toBe(1); + expect(order.lastError).toBe('gas estimation failed'); + expect(rollbackSpy).toHaveBeenCalledTimes(1); + expect(repoSaveSpy).toHaveBeenCalledTimes(1); + }); + + it('does not roll back on a PayoutBroadcastException (send was reached, fail-closed)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: null }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + + await strategy.callHandleBroadcastError( + order, + new PayoutBroadcastException('tx may be in-flight'), + payoutOrderRepo, + ); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('does not roll back a re-entry with payoutTxId already set (would break speedup/expired-retry nonce reuse)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: 'OLD_TX' }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + + await strategy.callHandleBroadcastError(order, new Error('replacement transaction underpriced'), payoutOrderRepo); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.payoutTxId).toBe('OLD_TX'); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('stops rolling back once retryCount reaches the cap (escalates to PAYOUT_UNCERTAIN downstream)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ + status: PayoutOrderStatus.PAYOUT_DESIGNATED, + payoutTxId: null, + retryCount: Config.payout.maxPreBroadcastRetries, + }); + const rollbackSpy = jest.spyOn(order, 'rollbackPayoutDesignation'); + + await strategy.callHandleBroadcastError(order, new Error('gas estimation failed'), payoutOrderRepo); + + expect(order.status).toBe(PayoutOrderStatus.PAYOUT_DESIGNATED); + expect(order.retryCount).toBe(Config.payout.maxPreBroadcastRetries); + expect(rollbackSpy).not.toHaveBeenCalled(); + expect(repoSaveSpy).not.toHaveBeenCalled(); + }); + + it('records String(e) as the failure message for a non-Error rejection', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({ status: PayoutOrderStatus.PAYOUT_DESIGNATED, payoutTxId: null }); + + await strategy.callHandleBroadcastError(order, 'rpc failure string', payoutOrderRepo); + + expect(order.lastError).toBe('rpc failure string'); + expect(order.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); + }); + + describe('#supportsSpeedup', () => { + it('defaults to false on the base strategy', () => { + const strategy = new TestStrategyWrapper(); + + expect(strategy.supportsSpeedup).toBe(false); + }); + + it('is overridden to true on EvmStrategy', () => { + const payoutEvmService = mock(); + const payoutOrderRepo = mock(); + const strategy = new EvmStrategyWrapper(payoutEvmService, payoutOrderRepo); + + expect(strategy.supportsSpeedup).toBe(true); + }); + }); + + describe('#onModuleInit(...) / #onModuleDestroy(...)', () => { + let strategy: TestStrategyWrapper; + let registry: PayoutStrategyRegistry; + + beforeEach(() => { + strategy = new TestStrategyWrapper(); + registry = mock(); + + // registry is an @Inject() readonly property on PayoutStrategy; there is no DI in the unit + // test, so define it directly on the instance (direct assignment fails on a readonly field). + Object.defineProperty(strategy, 'registry', { value: registry, configurable: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers itself with the payout strategy registry on module init', () => { + strategy.onModuleInit(); + + expect(registry.add).toHaveBeenCalledWith( + { blockchain: Blockchain.BITCOIN, assetType: AssetType.COIN }, + strategy, + ); + }); + + it('deregisters itself from the payout strategy registry on module destroy', () => { + strategy.onModuleDestroy(); + + expect(registry.remove).toHaveBeenCalledWith({ blockchain: Blockchain.BITCOIN, assetType: AssetType.COIN }); + }); + }); + + describe('#canRetryFailedPayout(...)', () => { + it('defaults to false on the base strategy (whitelist approach: no retry unless overridden)', async () => { + const strategy = new TestStrategyWrapper(); + const order = createCustomPayoutOrder({}); + + await expect(strategy.canRetryFailedPayout(order)).resolves.toBe(false); + }); + }); + + describe('#feeAsset(...)', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('resolves the fee asset via getFeeAsset() once and caches it on subsequent calls', async () => { + const strategy = new TestStrategyWrapper(); + const asset = createDefaultAsset(); + const getFeeAssetSpy = jest.spyOn(strategy as any, 'getFeeAsset').mockResolvedValue(asset); + + const first = await strategy.feeAsset(); + const second = await strategy.feeAsset(); + + expect(first).toBe(asset); + expect(second).toBe(asset); + expect(getFeeAssetSpy).toHaveBeenCalledTimes(1); + }); + }); +}); + +class TestStrategyWrapper extends PayoutStrategy { + get blockchain(): Blockchain { + return Blockchain.BITCOIN; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + async doPayout(_orders: PayoutOrder[]): Promise { + throw new Error('Method not implemented.'); + } + + async checkPayoutCompletionData(_orders: PayoutOrder[]): Promise { + throw new Error('Method not implemented.'); + } + + async estimateFee(): Promise { + throw new Error('Method not implemented.'); + } + + async estimateBlockchainFee(): Promise { + throw new Error('Method not implemented.'); + } + + protected async getFeeAsset(): Promise { + throw new Error('Method not implemented.'); + } + + async callDesignateBeforeBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { + return this.designateBeforeBroadcast(order, repo); + } + + async callHandleBroadcastError(order: PayoutOrder, e: unknown, repo: PayoutOrderRepository): Promise { + return this.handleBroadcastError(order, e, repo); + } +} + +class EvmStrategyWrapper extends EvmStrategy { + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected dispatchPayout(_order: PayoutOrder): Promise { + throw new Error('Method not implemented.'); + } + + protected getCurrentGasForTransaction(): Promise { + return Promise.resolve(0); + } + + protected getFeeAsset(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts index a37fa24ce5..061a63847f 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/arkade.strategy.ts @@ -45,12 +45,16 @@ export class ArkadeStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Arkade payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/bitcoin-based.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/bitcoin-based.strategy.ts index 396d78b6ac..6522db1756 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/bitcoin-based.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/bitcoin-based.strategy.ts @@ -5,6 +5,7 @@ import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { Util } from 'src/shared/utils/util'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { PayoutBroadcastException } from 'src/subdomains/supporting/payout/exceptions/payout-broadcast.exception'; import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; import { PayoutBitcoinBasedService, @@ -153,7 +154,12 @@ export abstract class BitcoinBasedStrategy extends PayoutStrategy { await this.trackPayoutFailure(orders, e); - if (e.message.includes('timeout')) throw e; + // A PayoutBroadcastException means the underlying client reached the actual on-chain + // broadcast call (tx may already be in-flight) - fail-closed, keep PAYOUT_DESIGNATED so + // the cron escalates to PAYOUT_UNCERTAIN instead of double-spending on rollback+retry. + // Any other error (including one that happens to mention "timeout") is provably + // pre-broadcast and safe to roll back for auto-retry. + if (e instanceof PayoutBroadcastException) throw e; await this.rollbackPayoutDesignation(orders); diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/cardano.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/cardano.strategy.ts index d615d154a6..36c1611ba2 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/cardano.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/cardano.strategy.ts @@ -37,12 +37,16 @@ export abstract class CardanoStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Cardano payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts index d9d60acc45..67057bd515 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts @@ -26,6 +26,10 @@ export abstract class EvmStrategy extends PayoutStrategy { protected abstract dispatchPayout(order: PayoutOrder): Promise; protected abstract getCurrentGasForTransaction(token?: Asset): Promise; + override get supportsSpeedup(): boolean { + return true; + } + async estimateFee(asset: Asset): Promise { const gasPerTransaction = await this.txFees.get(asset.id.toString(), () => this.getCurrentGasForTransaction(asset)); @@ -39,12 +43,17 @@ export abstract class EvmStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); + order.resetPayoutRetry(); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing EVM payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts index a6decb1a0b..11e4adf5d9 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts @@ -37,12 +37,16 @@ export abstract class InternetComputerStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing ICP payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts index a8b150ea5c..3a5646c719 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts @@ -1,9 +1,12 @@ import { Inject, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { PayoutOrder } from '../../../../entities/payout-order.entity'; +import { PayoutBroadcastException } from '../../../../exceptions/payout-broadcast.exception'; +import { PayoutOrderRepository } from '../../../../repositories/payout-order.repository'; import { PayoutStrategyRegistry } from './payout.strategy-registry'; export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { @@ -39,5 +42,37 @@ export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { return false; } + // Speedup replaces a pending tx by reusing its nonce; only EVM implements this. + // Default false so speedupTransaction rejects chains without replacement semantics, + // which would otherwise broadcast a second, independent transaction (double payout). + get supportsSpeedup(): boolean { + return false; + } + + // Persist PAYOUT_DESIGNATED BEFORE broadcasting (fail-closed, mirrors the Bitcoin path): + // a reboot between broadcast and save must not leave the order re-selectable by the payout + // cron, which would double-pay. Only designates on the first attempt; a re-entry with + // payoutTxId already set (EVM speedup/expired-retry) keeps its status so nonce reuse stays intact. + protected async designateBeforeBroadcast(order: PayoutOrder, repo: PayoutOrderRepository): Promise { + if (!order.payoutTxId) { + order.designatePayout(); + await repo.save(order); + } + } + + // Broadcast-boundary error handling shared by all non-Bitcoin strategies. A PayoutBroadcastException + // means the send was reached (tx may be in-flight) → fail-closed: leave PAYOUT_DESIGNATED for + // processFailedOrders → PAYOUT_UNCERTAIN. A plain error means the tx provably never left → roll back + // to PREPARATION_CONFIRMED for auto-retry, but only on the first attempt (payoutTxId unset — never + // break speedup/expired-retry nonce reuse) and capped by retryCount to escalate a permanent failure. + protected async handleBroadcastError(order: PayoutOrder, e: unknown, repo: PayoutOrderRepository): Promise { + const preBroadcast = !(e instanceof PayoutBroadcastException); + if (preBroadcast && !order.payoutTxId && order.retryCount < Config.payout.maxPreBroadcastRetries) { + order.recordPayoutFailure(e instanceof Error ? e.message : String(e)); + order.rollbackPayoutDesignation(); + await repo.save(order); + } + } + protected abstract getFeeAsset(): Promise; } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/solana.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/solana.strategy.ts index eeabe76922..29f760faaa 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/solana.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/solana.strategy.ts @@ -37,12 +37,16 @@ export abstract class SolanaStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Solana payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/tron.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/tron.strategy.ts index b7c5bb7151..729f7a3c89 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/tron.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/tron.strategy.ts @@ -37,12 +37,16 @@ export abstract class TronStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Tron payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts index 2e5b101df6..491812c5d8 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/lightning.strategy.ts @@ -39,6 +39,8 @@ export class LightningStrategy extends PayoutStrategy { if (await this.isHealthy()) { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const address = order.destinationAddress; const amount = order.amount; @@ -46,6 +48,8 @@ export class LightningStrategy extends PayoutStrategy { await this.finishDoPayout(order, txId); } catch (e) { this.logger.error(`Error while executing Lightning payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts index d9fc384e5c..08cec3d8f6 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/spark.strategy.ts @@ -45,12 +45,16 @@ export class SparkStrategy extends PayoutStrategy { async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { + await this.designateBeforeBroadcast(order, this.payoutOrderRepo); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); } catch (e) { this.logger.error(`Error while executing Spark payout order ${order.id}:`, e); + + await this.handleBroadcastError(order, e, this.payoutOrderRepo); } } } diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts index 4f385b3853..bfe9f84440 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-compliance.service.spec.ts @@ -24,14 +24,18 @@ import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { SupportIssueService } from 'src/subdomains/supporting/support-issue/services/support-issue.service'; +import { HoldersDto } from 'src/subdomains/supporting/realunit/dto/realunit.dto'; import { REALUNIT_VISIBLE_TX_ASSETS, RealUnitComplianceService, } from 'src/subdomains/supporting/realunit/realunit-compliance.service'; +import { RealUnitService } from 'src/subdomains/supporting/realunit/realunit.service'; import { RealUnitScopeService } from 'src/subdomains/supporting/realunit/realunit-scope.service'; describe('RealUnitComplianceService', () => { @@ -39,6 +43,8 @@ describe('RealUnitComplianceService', () => { let scopeService: DeepMocked; let userDataService: DeepMocked; + let userService: DeepMocked; + let realUnitService: DeepMocked; let transactionService: DeepMocked; let bankDataService: DeepMocked; let buyService: DeepMocked; @@ -65,6 +71,18 @@ describe('RealUnitComplianceService', () => { return Object.assign(new KycStep(), values); } + // Balance resolution defaults: the member has no wallet addresses, but the indexer DOES resolve (a single + // foreign holder keeps the set non-empty) — so members resolve to 0 (holds nothing), not unknown. Share + // token with 0 decimals. + function mockEmptyBalances(): void { + userService.getUsersByUserDataIds.mockResolvedValue([]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xf000000000000000000000000000000000000000', balance: '0' }], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + realUnitService.getRealuAsset.mockResolvedValue({ decimals: 0 } as Asset); + } + // Baseline mocks for a dossier of an existing member with no data; tests override what they exercise. function mockDossierDefaults(): void { scopeService.assertCustomer.mockResolvedValue(undefined); @@ -83,6 +101,9 @@ describe('RealUnitComplianceService', () => { beforeEach(() => { scopeService = createMock(); userDataService = createMock(); + userService = createMock(); + realUnitService = createMock(); + mockEmptyBalances(); transactionService = createMock(); bankDataService = createMock(); buyService = createMock(); @@ -98,6 +119,8 @@ describe('RealUnitComplianceService', () => { service = new RealUnitComplianceService( scopeService, userDataService, + userService, + realUnitService, transactionService, bankDataService, buyService, @@ -273,6 +296,21 @@ describe('RealUnitComplianceService', () => { expect(dossier.checks.identCheck).toBeUndefined(); expect(dossier.checks.nameCheck).toBeUndefined(); }); + + it('resolves the member REALU balance in the dossier detail', async () => { + mockDossierDefaults(); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xAaA', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xaaa', balance: '250' }], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const dossier = await service.getReducedDossier(1); + + expect(dossier.balance).toBe(250); + }); }); describe('searchCustomers', () => { @@ -314,6 +352,191 @@ describe('RealUnitComplianceService', () => { expect(result[0]).not.toHaveProperty('bankTx'); }); + it('sums the indexer balances over all wallet addresses of a member', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xAbC', userData: { id: 1 } as UserData }), + Object.assign(new User(), { address: '0xDeF', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [ + { address: '0xabc', balance: '100' }, + { address: '0xdef', balance: '50' }, + { address: '0x999', balance: '7' }, // foreign holder, not one of the member's addresses + ], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBe(150); + }); + + it('reports the balance as unknown (undefined) when the indexer is unavailable', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + realUnitService.getHolders.mockRejectedValue(new Error('indexer down')); + + const result = await service.searchCustomers(); + + expect(result).toHaveLength(1); + expect(result[0].balance).toBeUndefined(); + }); + + it('reports the balance as unknown (undefined) when the indexer resolves but returns no holders', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ holders: [], pageInfo: { hasNextPage: false } } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + + it('marks the member with an unparsable holder balance as unknown and leaves the others correct', async () => { + scopeService.getCustomerIds.mockResolvedValue([1, 2]); + userDataService.getUserDataByIds.mockResolvedValue([ + Object.assign(new UserData(), { id: 1 }), + Object.assign(new UserData(), { id: 2 }), + ]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + Object.assign(new User(), { address: '0xdef', userData: { id: 2 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [ + { address: '0xabc', balance: 'not-a-number' }, // member 1: unresolvable -> undefined, not a false 0 + { address: '0xdef', balance: '50' }, // member 2: unaffected by the poisoned record + ], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result.find((u) => u.id === 1)?.balance).toBeUndefined(); + expect(result.find((u) => u.id === 2)?.balance).toBe(50); + }); + + it('reports the balance as unknown when any of a member address balances is unparsable (no partial sum)', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + Object.assign(new User(), { address: '0xdef', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [ + { address: '0xabc', balance: '50' }, // valid first... + { address: '0xdef', balance: 'not-a-number' }, // ...then malformed -> whole member unknown, not 50 + ], + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + + it('reports the balance as unknown when the holder pagination exceeds the page cap', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + // Non-empty foreign holders on every page: the map is never empty, so the undefined result is produced by + // the page-cap guard alone (not by the empty-sweep guard) — removing `if (!complete) throw` would resolve + // the member to 0 and fail this test. + let cursor = 0; + realUnitService.getHolders.mockImplementation( + async () => + ({ + holders: [{ address: `0xf00${cursor}`, balance: '1' }], + pageInfo: { hasNextPage: true, endCursor: `cursor-${cursor++}` }, + }) as HoldersDto, + ); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + + it('detects a stalled indexer that repeats the same pagination cursor (on the second page)', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xfff', balance: '1' }], + pageInfo: { hasNextPage: true, endCursor: 'stuck' }, // same cursor every call + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + // The repeated-cursor guard must catch it on page 2, not only after the 100-page cap. + expect(realUnitService.getHolders).toHaveBeenCalledTimes(2); + }); + + it('reports the balance as unknown when a holder balance is an empty string', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xabc', balance: '' }], // present but unusable -> unresolved, not a false 0 + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + + it('aggregates a member holdings across multiple legitimately-cursored pages', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + Object.assign(new User(), { address: '0xdef', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders + .mockResolvedValueOnce({ + holders: [{ address: '0xabc', balance: '40' }], + pageInfo: { hasNextPage: true, endCursor: 'p1' }, + } as HoldersDto) + .mockResolvedValueOnce({ + holders: [{ address: '0xdef', balance: '60' }], // only reachable by advancing the cursor to page 2 + pageInfo: { hasNextPage: false }, + } as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBe(100); + expect(realUnitService.getHolders).toHaveBeenCalledTimes(2); + }); + + it('reports the balance as unknown (undefined) when the indexer signals another page without a cursor', async () => { + scopeService.getCustomerIds.mockResolvedValue([1]); + userDataService.getUserDataByIds.mockResolvedValue([Object.assign(new UserData(), { id: 1 })]); + userService.getUsersByUserDataIds.mockResolvedValue([ + Object.assign(new User(), { address: '0xabc', userData: { id: 1 } as UserData }), + ]); + realUnitService.getHolders.mockResolvedValue({ + holders: [{ address: '0xabc', balance: '100' }], + pageInfo: { hasNextPage: true }, + } as unknown as HoldersDto); + + const result = await service.searchCustomers(); + + expect(result[0].balance).toBeUndefined(); + }); + it('returns an empty list (fail-closed) without a key when there are no RealUnit customers', async () => { scopeService.getCustomerIds.mockResolvedValue([]); diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-registration.dto.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-registration.dto.spec.ts index 8321f43b5b..f99e9a8fd2 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-registration.dto.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-registration.dto.spec.ts @@ -71,6 +71,59 @@ describe('RealUnitRegistrationDto (class-validator decorators)', () => { expect(errors.find((e) => e.property === 'countryAndTINs')).toBeUndefined(); }); + // When swissTaxResidence is true BUT countryAndTINs is present, validators must still run + // (previously @ValidateIf skipped @IsArray and a non-array body reached the service as a 500). + it('rejects a non-array countryAndTINs when swissTaxResidence is true (F3 regression)', async () => { + const errors = await validate(build({ swissTaxResidence: true, countryAndTINs: 'pwned' })); + const tinError = errors.find((e) => e.property === 'countryAndTINs'); + expect(tinError).toBeDefined(); + expect(tinError!.constraints).toHaveProperty('isArray'); + }); + + it('rejects a TIN longer than 64 characters via @MaxLength', async () => { + const errors = await validate( + build({ swissTaxResidence: true, countryAndTINs: [{ country: 'DE', tin: 'x'.repeat(65) }] }), + ); + const tinError = errors.find((e) => e.property === 'countryAndTINs'); + expect(tinError).toBeDefined(); + expect(tinError!.children?.length).toBeGreaterThan(0); + const nestedTinError = tinError!.children![0].children?.find((e) => e.property === 'tin'); + expect(nestedTinError?.constraints).toHaveProperty('maxLength'); + }); + + it('rejects more than 10 countryAndTINs entries via @ArrayMaxSize', async () => { + // Valid 2-letter codes so only ArrayMaxSize (not nested Matches) is the failure mode. + const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE', 'PL']; + const countryAndTINs = countries.map((country, i) => ({ country, tin: `TIN${i}` })); + const errors = await validate(build({ swissTaxResidence: false, countryAndTINs })); + const tinError = errors.find((e) => e.property === 'countryAndTINs'); + expect(tinError).toBeDefined(); + expect(tinError!.constraints).toHaveProperty('arrayMaxSize'); + }); + + it('trims TIN whitespace via string-safe @Transform', () => { + const dto = build({ swissTaxResidence: false, countryAndTINs: [{ country: 'DE', tin: ' DE123 ' }] }); + expect(dto.countryAndTINs![0].tin).toBe('DE123'); + }); + + // Non-string tin must not crash @Transform (Util.trim would throw TypeError => HTTP 500); + // the value must fall through so @IsString can reject it as a clean 400. + it('rejects a non-string tin as isString without throwing (400 not 500 regression)', async () => { + const errors = await validate(build({ swissTaxResidence: true, countryAndTINs: [{ country: 'DE', tin: 12345 }] })); + const tinError = errors.find((e) => e.property === 'countryAndTINs'); + expect(tinError).toBeDefined(); + expect(tinError!.children?.length).toBeGreaterThan(0); + const nestedTinError = tinError!.children![0].children?.find((e) => e.property === 'tin'); + expect(nestedTinError?.constraints).toHaveProperty('isString'); + }); + + it('accepts exactly 10 countryAndTINs entries (ArrayMaxSize boundary)', async () => { + const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE']; + const countryAndTINs = countries.map((country, i) => ({ country, tin: `TIN${i}` })); + const errors = await validate(build({ swissTaxResidence: false, countryAndTINs })); + expect(errors.find((e) => e.property === 'countryAndTINs')).toBeUndefined(); + }); + // --- kycData nested factory (@Type(() => KycPersonalData)) --- // it('instantiates kycData through the KycPersonalData type factory', () => { diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index caca3640f0..db7ad7f339 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1668,6 +1668,33 @@ describe('RealUnitService', () => { expect(logService.create).toHaveBeenCalledWith(expect.objectContaining({ severity: LogSeverity.ERROR })); }); + it('self-heals on retry: after a persist failure the client retry re-POSTs (harmless upsert) and completes', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + httpService.post.mockResolvedValue({} as any); + // first attempt: the POST succeeds but the COMPLETED persist fails -> the transaction rolls back, no row + aktionariatTxManager.save.mockRejectedValueOnce(new Error('db down')); + + const first = await (service as any).forwardRegistration(fakeUserData(10), dto); + + // nothing durable was written -> returns false, but the failed attempt leaves no blocking row + expect(first).toBe(false); + expect(httpService.post).toHaveBeenCalledTimes(1); + + // client retry: registerUser is an idempotent upsert, so re-POSTing is harmless; the persist now succeeds + const second = await (service as any).forwardRegistration(fakeUserData(10), dto); + + expect(second).toBe(true); + // the POST runs unconditionally (outside the persist txn), so its count alone does not prove the retry; + // the self-heal is shown by the second attempt actually persisting a COMPLETED row — the rolled-back + // first attempt left no active row to short-circuit it. + expect(httpService.post).toHaveBeenCalledTimes(2); + expect(aktionariatTxManager.save).toHaveBeenCalledTimes(2); + const persisted = (aktionariatRegistrationRepo.create as jest.Mock).mock.calls.at(-1)[0]; + expect(persisted.status).toBe(ReviewStatus.COMPLETED); // the retry persists the COMPLETED registration + }); + it('re-checks idempotency inside a per-wallet-user advisory lock and does not persist again', async () => { const wallet = softwareWallet.address; const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); @@ -1694,6 +1721,25 @@ describe('RealUnitService', () => { expect(aktionariatTxManager.save).not.toHaveBeenCalled(); }); + it('lifts the KYC level on the in-lock idempotent outcome (concurrent completion of the same wallet)', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + userService.getUserByAddress.mockResolvedValue({ id: 55 } as any); + httpService.post.mockResolvedValue({} as any); + // a concurrent caller already completed this wallet (same signature) -> our persist short-circuits idempotent + aktionariatTxManager.findOne.mockResolvedValue({ id: 9, status: ReviewStatus.COMPLETED, signature }); + + const ok = await (service as any).forwardRegistration(fakeUserData(10), dto); + + expect(ok).toBe(true); + expect(aktionariatTxManager.save).not.toHaveBeenCalled(); // no duplicate persist on the idempotent outcome + // the idempotent outcome must still (best-effort) lift THIS caller's KYC level, else its buy/sell gate stays shut + expect(userDataService.updateUserDataInternal).toHaveBeenCalledTimes(1); + const [, update] = (userDataService.updateUserDataInternal as jest.Mock).mock.calls[0]; + expect(update.kycLevel).toBe(20); + }); + it('rejects an in-lock COMPLETED short-circuit when the incoming signature differs', async () => { const wallet = softwareWallet.address; const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); @@ -2170,10 +2216,26 @@ describe('RealUnitService', () => { it('returns the idempotent result for an existing current-wallet registration', async () => { userService.getUserByAddress.mockResolvedValue({ - userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com' }, + userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', tin: null }, } as any); + // After the F2 fix the isForCurrentWallet branch reconstructs the registration via + // toRegistrationDto (reads signedPayloadData) before syncing user_data.tin from the record. jest.spyOn(service as any, 'findRegistration').mockResolvedValue({ - registration: { id: 3, signature: '0xsig', status: ReviewStatus.COMPLETED }, + registration: { + id: 3, + signature: '0xsig', + status: ReviewStatus.COMPLETED, + signedPayloadData: { + email: 'max@example.com', + name: 'Max', + walletAddress: '0xabc', + signature: '0xsig', + registrationDate: '2026-01-01', + swissTaxResidence: true, + addressCountry: 'CH', + }, + kycDataObj: { accountType: 'Personal' }, + }, isForCurrentWallet: true, }); @@ -2208,6 +2270,10 @@ describe('RealUnitService', () => { expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); expect(forwardSpy).toHaveBeenCalled(); + // Personal KYC fields stay untouched when they already match. dto has no countryAndTINs and + // userData.tin is empty → tin persist is a no-op (no destructive null write). + expect(userDataService.updatePersonalData).not.toHaveBeenCalled(); + expect(userDataService.updateUserDataInternal).not.toHaveBeenCalled(); }); it('persists personal data for a first-time customer (no existing firstname) before forwarding', async () => { @@ -2255,6 +2321,111 @@ describe('RealUnitService', () => { expect(status).toBe(RealUnitRegistrationStatus.FORWARDING_FAILED); }); + + it('F1: does not call forwardRegistration when countryAndTINs is too large', async () => { + // Exercise the real tax-residence guard (serialized-length fail-closed) without the full EIP-712 path. + jest.spyOn(service as any, 'validateRegistrationDto').mockImplementation(async (d: any) => { + (service as any).validateTaxResidenceCoversAddress(d); + }); + jest.spyOn(service as any, 'serializeCountryAndTins').mockReturnValue('x'.repeat(1025)); + const forwardSpy = jest.spyOn(service as any, 'forwardRegistration').mockResolvedValue(true); + + const oversizedDto: any = { + walletAddress: '0xabc', + signature: '0xsig', + email: 'max@example.com', + addressCountry: 'DE', + swissTaxResidence: false, + countryAndTINs: [{ country: 'DE', tin: 'DE123456789' }], + kycData: {}, + }; + userService.getUserByAddress.mockResolvedValue({ + userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', firstname: 'Max' }, + } as any); + + await expect(service.completeRegistration(1, oversizedDto)).rejects.toThrow(/countryAndTINs is too large/); + expect(forwardSpy).not.toHaveBeenCalled(); + }); + + it('F2: does not write user_data.tin when the idempotent path rejects a signature mismatch', async () => { + userService.getUserByAddress.mockResolvedValue({ + userData: { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', tin: null }, + } as any); + jest.spyOn(service as any, 'findRegistration').mockResolvedValue({ + registration: { + id: 3, + signature: '0xSTORED_SIGNATURE', + status: ReviewStatus.COMPLETED, + signedPayloadData: { + email: 'max@example.com', + name: 'Max', + walletAddress: '0xabc', + signature: '0xSTORED_SIGNATURE', + registrationDate: '2026-01-01', + swissTaxResidence: true, + addressCountry: 'CH', + countryAndTINs: [{ country: 'DE', tin: 'DE-STORED' }], + }, + kycDataObj: { accountType: 'Personal' }, + }, + isForCurrentWallet: true, + }); + // Incoming signature does NOT match the stored one; body carries a different TIN set. + const mismatchDto: any = { + ...dto, + signature: '0xMISMATCHING_SIGNATURE', + countryAndTINs: [{ country: 'FR', tin: 'FR-ATTACKER' }], + }; + + await expect(service.completeRegistration(1, mismatchDto)).rejects.toThrow(BadRequestException); + + const tinWrites = (userDataService.updateUserDataInternal as jest.Mock).mock.calls + .map((c) => c[1]) + .filter((u) => u && Object.prototype.hasOwnProperty.call(u, 'tin')); + expect(tinWrites).toHaveLength(0); + expect(logService.create).not.toHaveBeenCalledWith(expect.objectContaining({ subsystem: 'UserDataTin' })); + }); + + it('F2: syncs user_data.tin from the stored registration, not the request body, on idempotent retry', async () => { + const storedTins = [{ country: 'DE', tin: 'DE-STORED' }]; + const bodyTins = [{ country: 'FR', tin: 'FR-FROM-BODY' }]; + const userData: any = { + id: 1, + kycLevel: KycLevel.LEVEL_10, + mail: 'max@example.com', + tin: null, + }; + userService.getUserByAddress.mockResolvedValue({ userData } as any); + jest.spyOn(service as any, 'findRegistration').mockResolvedValue({ + registration: { + id: 3, + signature: '0xsig', + status: ReviewStatus.COMPLETED, + signedPayloadData: { + email: 'max@example.com', + name: 'Max', + walletAddress: '0xabc', + signature: '0xsig', + registrationDate: '2026-01-01', + swissTaxResidence: true, + addressCountry: 'CH', + countryAndTINs: storedTins, + }, + kycDataObj: { accountType: 'Personal' }, + }, + isForCurrentWallet: true, + }); + logService.create.mockResolvedValue({} as any); + + const status = await service.completeRegistration(1, { ...dto, countryAndTINs: bodyTins }); + + expect(status).toBe(RealUnitRegistrationStatus.ALREADY_REGISTERED); + const tinWrites = (userDataService.updateUserDataInternal as jest.Mock).mock.calls + .map((c) => c[1]) + .filter((u) => u && Object.prototype.hasOwnProperty.call(u, 'tin')); + expect(tinWrites).toEqual([{ tin: JSON.stringify(storedTins) }]); + expect(tinWrites[0].tin).not.toContain('FR-FROM-BODY'); + }); }); describe('ponder queries (GraphQL injection protection)', () => { @@ -3009,6 +3180,201 @@ describe('RealUnitService', () => { await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); }); + // --- Tax residence must cover the residence (address) country --- + // countryAndTINs is NOT part of the EIP-712 envelope — attach it AFTER signing. + // + // Scenario matrix (service-level validateRegistrationDto): + // S1 CH + swissTaxResidence, countryAndTINs undefined/empty → PASS + // S2 DE + DE TIN → PASS + // S3 CH + swissTaxResidence + additional FR TIN → PASS + // S4 DE + swissTaxResidence + DE TIN → PASS + // S5 DE + multi (DE, FR, US) TINs → PASS + // N1 DE + swissTaxResidence, no countryAndTINs → reject (must include DE) + // N2 DE + only FR TIN → reject + // N3 CH + !swissTaxResidence + only FR → reject (CH not covered) + // N4 duplicate countries in countryAndTINs → reject + + const attachTins = (dto: any, countryAndTINs: { country: string; tin: string }[] | undefined) => { + dto.countryAndTINs = countryAndTINs; + return dto; + }; + + it('S1: passes when CH residence is covered by swissTaxResidence alone (countryAndTINs undefined)', async () => { + const dto = await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: true }), humanKyc()); + // no countryAndTINs attached + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S1: passes when CH residence is covered by swissTaxResidence alone (countryAndTINs empty)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: true }), humanKyc()), + [], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S2: passes when a DE residence is covered by a DE countryAndTINs entry', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [{ country: 'DE', tin: 'DE123456789' }], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S3: passes when a CH residence is covered and an additional FR tax country is declared', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: true }), humanKyc()), + [{ country: 'FR', tin: 'FR111111111' }], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S4: passes when a DE residence is covered by DE TIN even with swissTaxResidence true', async () => { + // Living in DE requires DE among tax residences; swissTaxResidence alone does NOT cover DE. + // With both flags set correctly (swiss + DE TIN) the registration is valid. + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: true }), humanKyc()), + [{ country: 'DE', tin: 'DE123456789' }], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('S5: passes when a DE residence is covered and multiple additional tax countries are declared', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [ + { country: 'DE', tin: 'DE123456789' }, + { country: 'FR', tin: 'FR111111111' }, + { country: 'US', tin: 'US999999999' }, + ], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('passes when a DE residence is covered and an additional AT tax country is declared (extra multi-residency)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: true }), humanKyc()), + [ + { country: 'DE', tin: 'DE123456789' }, + { country: 'AT', tin: 'AT987654321' }, + ], + ); + await expect((service as any).validateRegistrationDto(dto)).resolves.toBeUndefined(); + }); + + it('N1: throws when a DE residence has only swissTaxResidence and no DE countryAndTINs entry', async () => { + const dto = await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: true }), humanKyc()); + // countryAndTINs intentionally omitted — DE address must still appear among tax residences + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(BadRequestException); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /Tax residence must include the residence country \(DE\)/, + ); + }); + + it('N2: throws when a DE residence is missing from the declared tax residences (only FR)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [{ country: 'FR', tin: 'FR111111111' }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(BadRequestException); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /Tax residence must include the residence country \(DE\)/, + ); + }); + + it('N3: throws when a CH residence is not covered (swissTaxResidence false, only FR)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: false }), humanKyc()), + [{ country: 'FR', tin: 'FR111111111' }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(BadRequestException); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /Tax residence must include the residence country \(CH\)/, + ); + }); + + it('N4: throws when countryAndTINs contains duplicate countries', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [ + { country: 'DE', tin: 'DE111' }, + { country: 'DE', tin: 'DE222' }, + ], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(BadRequestException); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /countryAndTINs must not contain duplicate countries/, + ); + }); + + it('N5: throws when CH appears in countryAndTINs (must use swissTaxResidence instead)', async () => { + // CH address covered only via countryAndTINs.CH would bypass the swissTaxResidence flag. + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: false }), humanKyc()), + [{ country: 'CH', tin: 'should-not-be-here' }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/countryAndTINs must not include CH/); + }); + + it('N6: throws when a multi-residence TIN entry has an empty tin (even with swissTaxResidence true)', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'CH', swissTaxResidence: true }), humanKyc()), + [{ country: 'FR', tin: ' ' }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow( + /countryAndTINs.tin must be a non-empty string/, + ); + }); + + // --- F1/F3 defense-in-depth bounds on countryAndTINs (fail closed before forward) --- + + it('F1: rejects countryAndTINs whose serialized length exceeds MAX_SERIALIZED_TIN_LENGTH', async () => { + // Under the DTO bounds the worst-case serialization is ~890 chars; the serialized-length + // check is a fail-closed safety net for constant drift. Force it by stubbing serialize. + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [{ country: 'DE', tin: 'DE123456789' }], + ); + jest.spyOn(service as any, 'serializeCountryAndTins').mockReturnValue('x'.repeat(1025)); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/countryAndTINs is too large/); + }); + + it('F1: rejects more than 10 countryAndTINs entries at the service layer', async () => { + const countries = ['DE', 'FR', 'US', 'AT', 'IT', 'ES', 'NL', 'BE', 'PT', 'IE', 'PL']; + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + countries.map((country, i) => ({ country, tin: `TIN${i}` })), + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/more than 10 entries/); + }); + + it('F1: rejects a TIN longer than 64 characters at the service layer', async () => { + const dto = attachTins( + await buildDto(humanFields({ addressCountry: 'DE', swissTaxResidence: false }), humanKyc()), + [{ country: 'DE', tin: 'x'.repeat(65) }], + ); + await expect((service as any).validateRegistrationDto(dto)).rejects.toThrow(/must not exceed 64 characters/); + }); + + it('F3: rejects a non-array countryAndTINs with BadRequestException (not TypeError)', () => { + for (const countryAndTINs of ['pwned', { a: 1 }, 42] as any[]) { + expect(() => + (service as any).validateTaxResidenceCoversAddress({ + swissTaxResidence: true, + addressCountry: 'CH', + countryAndTINs, + }), + ).toThrow(BadRequestException); + expect(() => + (service as any).validateTaxResidenceCoversAddress({ + swissTaxResidence: true, + addressCountry: 'CH', + countryAndTINs, + }), + ).toThrow(/countryAndTINs must be an array/); + } + }); + it('resolveSignedRegistrationMessage normalizes a signature that lacks the 0x prefix', async () => { const fields = humanFields(); const signature = await wallet._signTypedData(domain, types, fields); @@ -3104,36 +3470,214 @@ describe('RealUnitService', () => { }); }); - describe('completeRegistration — first-time customer with tax-residence TINs (countryAndTINs persistence)', () => { + describe('completeRegistration — tax-residence TINs (audit-safe user_data.tin persistence)', () => { + // Data rule: never overwrite a DB value if the previous value would become unrecoverable. + // - New non-empty countryAndTINs → written AFTER forwardRegistration (signedPayload holds the event) + // with a before→after audit log. + // - Swiss-only / empty countryAndTINs → does NOT clear an existing non-null user_data.tin. + // validateRegistrationDto is mocked here — tax-residence rule coverage lives in its own describe. + + const TIN_DE = { country: 'DE', tin: 'DE123456789' }; + const TIN_FR = { country: 'FR', tin: 'FR111111111' }; + const TIN_US = { country: 'US', tin: 'US999999999' }; + const STALE = JSON.stringify([{ country: 'XX', tin: 'stale' }]); + + type TinScenario = { + id: string; + addressCountry: string; + swissTaxResidence: boolean; + countryAndTINs: { country: string; tin: string }[] | undefined | []; + /** Expected next value when previous tin is empty; null means no tin column write. */ + expectedTinWhenEmpty: string | null; + }; + + const tinScenarios: TinScenario[] = [ + { + id: 'S1', + addressCountry: 'CH', + swissTaxResidence: true, + countryAndTINs: undefined, + expectedTinWhenEmpty: null, + }, + { + id: 'S1-empty', + addressCountry: 'CH', + swissTaxResidence: true, + countryAndTINs: [], + expectedTinWhenEmpty: null, + }, + { + id: 'S2', + addressCountry: 'DE', + swissTaxResidence: false, + countryAndTINs: [TIN_DE], + expectedTinWhenEmpty: JSON.stringify([TIN_DE]), + }, + { + id: 'S3', + addressCountry: 'CH', + swissTaxResidence: true, + countryAndTINs: [TIN_FR], + expectedTinWhenEmpty: JSON.stringify([TIN_FR]), + }, + { + id: 'S4', + addressCountry: 'DE', + swissTaxResidence: true, + countryAndTINs: [TIN_DE], + expectedTinWhenEmpty: JSON.stringify([TIN_DE]), + }, + { + id: 'S5', + addressCountry: 'DE', + swissTaxResidence: false, + countryAndTINs: [TIN_DE, TIN_FR, TIN_US], + expectedTinWhenEmpty: JSON.stringify([TIN_DE, TIN_FR, TIN_US]), + }, + ]; + + let forwardSpy: jest.SpyInstance; + beforeEach(() => { jest.spyOn(service as any, 'validateRegistrationDto').mockResolvedValue(undefined); jest .spyOn(service as any, 'findRegistration') .mockResolvedValue({ registration: undefined, isForCurrentWallet: false }); - jest.spyOn(service as any, 'forwardRegistration').mockResolvedValue(true); + forwardSpy = jest.spyOn(service as any, 'forwardRegistration').mockResolvedValue(true); + (service as any).countryService.getCountryWithSymbol.mockResolvedValue({ id: 1, symbol: 'CH' }); + (service as any).languageService.getLanguageBySymbol.mockResolvedValue({ id: 1, symbol: 'DE' }); + logService.create.mockResolvedValue({} as any); }); - it('serializes the provided countryAndTINs into the tin field before forwarding', async () => { - const dto: any = { - walletAddress: '0xabc', - signature: '0xsig', - email: 'max@example.com', - kycData: { accountType: 'Personal' }, - nationality: 'CH', - birthday: '1990-01-01', - lang: 'DE', - countryAndTINs: [{ country: 'DE', tin: '12345' }], + const buildDto = (scenario: TinScenario): any => ({ + walletAddress: '0xabc', + signature: '0xsig', + email: 'max@example.com', + kycData: { accountType: 'Personal' }, + nationality: scenario.addressCountry, + birthday: '1990-01-01', + lang: 'DE', + addressCountry: scenario.addressCountry, + swissTaxResidence: scenario.swissTaxResidence, + countryAndTINs: scenario.countryAndTINs, + }); + + const tinUpdates = (): any[] => + (userDataService.updateUserDataInternal as jest.Mock).mock.calls + .map((c) => c[1]) + .filter((u) => u && Object.prototype.hasOwnProperty.call(u, 'tin')); + + describe.each(tinScenarios)( + '$id first-time customer (empty previous tin) — addressCountry=$addressCountry', + (scenario) => { + it(`forwards first, then writes tin only when non-empty (expected=${ + scenario.expectedTinWhenEmpty === null ? 'no write' : 'JSON' + })`, async () => { + const dto = buildDto(scenario); + const userData: any = { + id: 1, + kycLevel: KycLevel.LEVEL_10, + mail: 'max@example.com', + firstname: null, + tin: null, + }; + userService.getUserByAddress.mockResolvedValue({ userData } as any); + + const status = await service.completeRegistration(1, dto); + + expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); + expect(userDataService.updatePersonalData).toHaveBeenCalledWith(userData, dto.kycData); + // Personal-data update never includes tin (tin is written only after forward). + const personalUpdate = (userDataService.updateUserDataInternal as jest.Mock).mock.calls[0][1]; + expect(personalUpdate).not.toHaveProperty('tin'); + expect(forwardSpy).toHaveBeenCalledWith(userData, dto); + + const tinWrites = tinUpdates(); + if (scenario.expectedTinWhenEmpty === null) { + expect(tinWrites).toHaveLength(0); + expect(logService.create).not.toHaveBeenCalledWith(expect.objectContaining({ subsystem: 'UserDataTin' })); + } else { + expect(tinWrites).toEqual([{ tin: scenario.expectedTinWhenEmpty }]); + expect(logService.create).toHaveBeenCalledWith( + expect.objectContaining({ + system: 'RealUnit', + subsystem: 'UserDataTin', + message: expect.stringContaining('"previousTin":null'), + }), + ); + // Audit before column write: log must be called before the tin update. + const logOrder = (logService.create as jest.Mock).mock.invocationCallOrder.find((_, i) => { + const arg = (logService.create as jest.Mock).mock.calls[i][0]; + return arg?.subsystem === 'UserDataTin'; + }); + const tinOrder = (userDataService.updateUserDataInternal as jest.Mock).mock.invocationCallOrder.find( + (_, i) => { + const arg = (userDataService.updateUserDataInternal as jest.Mock).mock.calls[i][1]; + return arg && Object.prototype.hasOwnProperty.call(arg, 'tin'); + }, + ); + expect(logOrder).toBeLessThan(tinOrder!); + } + }); + }, + ); + + describe.each(tinScenarios)( + '$id existing personal data with stale tin — addressCountry=$addressCountry', + (scenario) => { + it('never destroys the previous non-null tin without a recoverable replacement', async () => { + const dto = buildDto(scenario); + const userData: any = { + id: 1, + kycLevel: KycLevel.LEVEL_10, + mail: 'max@example.com', + firstname: 'Max', + tin: STALE, + }; + userService.getUserByAddress.mockResolvedValue({ userData } as any); + jest.spyOn(service as any, 'isPersonalDataMatching').mockReturnValue(true); + + const status = await service.completeRegistration(1, dto); + + expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); + expect(userDataService.updatePersonalData).not.toHaveBeenCalled(); + expect(forwardSpy).toHaveBeenCalledWith(userData, dto); + + const tinWrites = tinUpdates(); + if (scenario.expectedTinWhenEmpty === null) { + // Swiss-only must NOT clear STALE — that would lose data not on the new payload. + expect(tinWrites).toHaveLength(0); + } else { + // Non-empty next set: overwrite after audit (new value also on signedPayload). + expect(tinWrites).toEqual([{ tin: scenario.expectedTinWhenEmpty }]); + const audit = (logService.create as jest.Mock).mock.calls.find( + (c) => c[0]?.subsystem === 'UserDataTin', + )?.[0]; + expect(audit).toBeDefined(); + const body = JSON.parse(audit.message); + expect(body.previousTin).toBe(STALE); + expect(body.nextTin).toBe(scenario.expectedTinWhenEmpty); + } + }); + }, + ); + + it('fails closed: does not overwrite tin when the before→after audit log cannot be written', async () => { + const dto = buildDto(tinScenarios.find((s) => s.id === 'S2')!); + const userData: any = { + id: 1, + kycLevel: KycLevel.LEVEL_10, + mail: 'max@example.com', + firstname: 'Max', + tin: STALE, }; - const userData: any = { id: 1, kycLevel: KycLevel.LEVEL_10, mail: 'max@example.com', firstname: null }; userService.getUserByAddress.mockResolvedValue({ userData } as any); - (service as any).countryService.getCountryWithSymbol.mockResolvedValue({ id: 1, symbol: 'CH' }); - (service as any).languageService.getLanguageBySymbol.mockResolvedValue({ id: 1, symbol: 'DE' }); - - const status = await service.completeRegistration(1, dto); + jest.spyOn(service as any, 'isPersonalDataMatching').mockReturnValue(true); + logService.create.mockRejectedValue(new Error('log down')); - expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); - const [, update] = (userDataService.updateUserDataInternal as jest.Mock).mock.calls[0]; - expect(update.tin).toBe(JSON.stringify([{ country: 'DE', tin: '12345' }])); + await expect(service.completeRegistration(1, dto)).rejects.toThrow('log down'); + // Registration forward already ran, but the column must not change without audit. + expect(tinUpdates()).toHaveLength(0); }); }); @@ -3238,6 +3782,33 @@ describe('RealUnitService', () => { country: { symbol: 'CH', id: 7 }, }); }); + + // F4: malformed / contract-violating user_data.tin must degrade the prefill loudly, never throw. + it('F4: degrades prefill to empty countryAndTINs when tin is not valid JSON', () => { + const userData: any = { id: 99, firstname: 'Erika', tin: 'not json' }; + const dto = (service as any).toUserDataDtoFromUserData(userData); + expect(dto).toBeDefined(); + expect(dto.countryAndTINs).toBeUndefined(); + expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('not valid JSON')); + }); + + it('F4: degrades prefill to empty countryAndTINs when tin is a non-array JSON value', () => { + const userData: any = { id: 99, firstname: 'Erika', tin: '{"a":1}' }; + const dto = (service as any).toUserDataDtoFromUserData(userData); + expect(dto).toBeDefined(); + expect(dto.countryAndTINs).toBeUndefined(); + expect((service as any).logger.error).toHaveBeenCalledWith(expect.stringContaining('not an array')); + }); + + it('F4: drops a contract-violating CH entry from the prefill without throwing', () => { + const userData: any = { id: 99, firstname: 'Erika', tin: '[{"country":"CH","tin":"x"}]' }; + const dto = (service as any).toUserDataDtoFromUserData(userData); + expect(dto).toBeDefined(); + expect(dto.countryAndTINs).toBeUndefined(); + expect((service as any).logger.error).toHaveBeenCalledWith( + expect.stringContaining('violating the registration contract'), + ); + }); }); describe('isPersonalDataMatching (organization account branch)', () => { diff --git a/src/subdomains/supporting/realunit/dto/realunit-compliance-dto.mapper.ts b/src/subdomains/supporting/realunit/dto/realunit-compliance-dto.mapper.ts index 4d6aab0a33..d450773abe 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-compliance-dto.mapper.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-compliance-dto.mapper.ts @@ -49,9 +49,10 @@ interface RealUnitDossierSlices { export class RealUnitComplianceDtoMapper { // --- CUSTOMER --- // - static toCustomerListDto(userData: UserData): RealUnitCustomerListDto { + static toCustomerListDto(userData: UserData, balance?: number): RealUnitCustomerListDto { return { id: userData.id, + balance, kycStatus: userData.kycStatus, kycLevel: userData.kycLevel, accountType: userData.accountType, @@ -60,9 +61,14 @@ export class RealUnitComplianceDtoMapper { }; } - static toCustomerDetailDto(userData: UserData, slices: RealUnitDossierSlices): RealUnitCustomerDetailDto { + static toCustomerDetailDto( + userData: UserData, + balance: number | undefined, + slices: RealUnitDossierSlices, + ): RealUnitCustomerDetailDto { return { id: userData.id, + balance, created: userData.created, accountType: userData.accountType, mail: userData.mail, diff --git a/src/subdomains/supporting/realunit/dto/realunit-compliance.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-compliance.dto.ts index 3d0b319f42..d75cfef0ee 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-compliance.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-compliance.dto.ts @@ -17,7 +17,8 @@ import { KycLevel, KycStatus, KycType } from 'src/subdomains/generic/user/models // constructed field-by-field from scratch (never the full DFX dossier minus keys), so no DFX-internal or AML // work-product field can ever leak by omission or drift. The nested collection DTOs below are RealUnit-specific and // STRUCTURALLY omit every DFX AML work product / compliance note (see the per-DTO notes) per the product owner's -// reduced-compliance-scope decision (2026-07-03). +// reduced-compliance-scope decision (2026-07-03) — with one deliberate exception: the two mandatory check +// evidences (ident + Dilisense name check) are exposed per the product owner's decision of 2026-07-09. export class RealUnitCustomerSearchQuery { @IsOptional() @@ -26,6 +27,9 @@ export class RealUnitCustomerSearchQuery { } export class RealUnitCustomerListDto { + // current REALU holdings (share count, summed over all wallet addresses); undefined = could not be resolved + balance?: number; + id: number; kycStatus: KycStatus; kycLevel?: KycLevel; @@ -159,6 +163,9 @@ export class RealUnitCustomerDetailDto { highRisk?: boolean; pep?: boolean; + // current REALU holdings (share count, summed over all wallet addresses); undefined = could not be resolved + balance?: number; + // --- Mandatory checks, resolved by the api (absent member = check missing) --- // checks: RealUnitChecksDto; diff --git a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts index 4c5f431041..29417a7ec0 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { + ArrayMaxSize, IsArray, IsBoolean, IsEmail, @@ -18,6 +19,14 @@ import { Util } from 'src/shared/utils/util'; import { KycPersonalData } from 'src/subdomains/generic/kyc/dto/input/kyc-data.dto'; import { DfxPhoneTransform, IsDfxPhone } from 'src/subdomains/generic/user/models/user-data/is-dfx-phone.validator'; +// Bounds for the `user_data.tin` snapshot, which stores countryAndTINs as a JSON array of +// {country, tin}. The column is varchar(1024); the bounded worst case (10 entries x 64-char TIN) +// serializes to ~890 characters and therefore always fits. Keeping the three limits together makes +// the DB invariant explicit instead of implicit in the JSON envelope arithmetic. +export const MAX_TAX_RESIDENCES = 10; +export const MAX_TIN_LENGTH = 64; +export const MAX_SERIALIZED_TIN_LENGTH = 1024; + export enum RealUnitUserType { HUMAN = 'HUMAN', CORPORATION = 'CORPORATION', @@ -84,6 +93,11 @@ export class CountryAndTin { @ApiProperty({ description: 'Tax identification number' }) @IsNotEmpty() @IsString() + @MaxLength(MAX_TIN_LENGTH) + // NOT Util.trim: class-transformer runs this before class-validator, so a non-string tin (e.g. a + // number) would make `value.trim()` throw a TypeError => HTTP 500 before @IsString can reject it as + // a 400. Trim only actual strings and let any other type fall through to @IsString. + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) tin: string; } @@ -186,10 +200,24 @@ export class AktionariatRegistrationDto { @IsEnum(RealUnitLanguage) lang: RealUnitLanguage; - @ApiPropertyOptional({ type: [CountryAndTin], description: 'Required if swissTaxResidence is false' }) - @ValidateIf((o: AktionariatRegistrationDto) => !o.swissTaxResidence) + @ApiPropertyOptional({ + type: [CountryAndTin], + description: + 'Tax residences with TINs for non-CH countries only (never CH — use swissTaxResidence). ' + + 'Required when swissTaxResidence is false. Multi-residence TINs may also be sent when ' + + 'swissTaxResidence is true (max 10 entries, each TIN max 64 chars). addressCountry must be ' + + 'covered: CH via swissTaxResidence, any other country via an entry here.', + }) + // Validate whenever the field is REQUIRED (no Swiss tax residence declared) OR whenever it is + // present at all. The previous condition (`!o.swissTaxResidence` alone) skipped EVERY validator — + // including @IsArray — as soon as swissTaxResidence was true, so a non-array body slipped through + // the pipe and crashed the service with a TypeError (HTTP 500 instead of 400). + @ValidateIf((o: AktionariatRegistrationDto) => !o.swissTaxResidence || o.countryAndTINs !== undefined) @IsNotEmpty({ message: 'countryAndTINs is required when swissTaxResidence is false' }) @IsArray() + @ArrayMaxSize(MAX_TAX_RESIDENCES, { + message: `countryAndTINs must not contain more than ${MAX_TAX_RESIDENCES} entries`, + }) @ValidateNested({ each: true }) @Type(() => CountryAndTin) countryAndTINs?: CountryAndTin[]; diff --git a/src/subdomains/supporting/realunit/realunit-compliance.service.ts b/src/subdomains/supporting/realunit/realunit-compliance.service.ts index 5bf9192c50..9ef2b65315 100644 --- a/src/subdomains/supporting/realunit/realunit-compliance.service.ts +++ b/src/subdomains/supporting/realunit/realunit-compliance.service.ts @@ -1,5 +1,7 @@ import { Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import JSZip from 'jszip'; +import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; +import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; import { Config } from 'src/config/config'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -18,11 +20,13 @@ import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { SupportIssueService } from 'src/subdomains/supporting/support-issue/services/support-issue.service'; import { RealUnitComplianceDtoMapper } from './dto/realunit-compliance-dto.mapper'; import { RealUnitCustomerDetailDto, RealUnitCustomerListDto, RealUnitKycFileDto } from './dto/realunit-compliance.dto'; +import { RealUnitService } from './realunit.service'; import { RealUnitScopeService } from './realunit-scope.service'; // Postgres integer upper bound: larger numeric keys cannot be an id and would fail the DB query @@ -58,9 +62,14 @@ export const REALUNIT_VISIBLE_TX_ASSETS: string[] = ['REALU', 'ZCHF']; export class RealUnitComplianceService { private readonly logger = new DfxLogger(RealUnitComplianceService); + // address (lowercase) -> raw REALU balance from the ponder indexer; refreshed lazily every 5 minutes + private readonly holderBalanceCache = new AsyncCache>(CacheItemResetPeriod.EVERY_5_MINUTES); + constructor( private readonly scopeService: RealUnitScopeService, private readonly userDataService: UserDataService, + private readonly userService: UserService, + private readonly realUnitService: RealUnitService, private readonly transactionService: TransactionService, private readonly bankDataService: BankDataService, private readonly buyService: BuyService, @@ -90,7 +99,9 @@ export class RealUnitComplianceService { 'id', ).sort((a, b) => a.id - b.id); - return members.map(RealUnitComplianceDtoMapper.toCustomerListDto); + const balances = await this.getMemberBalances(members.map((m) => m.id)); + + return members.map((m) => RealUnitComplianceDtoMapper.toCustomerListDto(m, balances.get(m.id))); } // --- REDUCED DOSSIER --- // @@ -124,7 +135,9 @@ export class RealUnitComplianceService { this.supportIssueService.getIssueEntities(id), ]); - return RealUnitComplianceDtoMapper.toCustomerDetailDto(userData, { + const balance = (await this.getMemberBalances([id])).get(id); + + return RealUnitComplianceDtoMapper.toCustomerDetailDto(userData, balance, { kycFiles: this.filterDownloadableFiles(kycFiles), kycSteps, transactions, @@ -251,6 +264,100 @@ export class RealUnitComplianceService { return files.filter((f) => REALUNIT_DOWNLOADABLE_FILE_TYPES.includes(f.type)); } + // Current REALU holdings per customer: sum of the indexer balances over all wallet addresses of the account. + // Returns share counts (asset decimals applied). Fail-open to an EMPTY map when the indexer or asset lookup is + // unavailable — the dashboard then shows the balance as unknown instead of failing the whole customer list. + private async getMemberBalances(userDataIds: number[]): Promise> { + try { + const [users, holderBalances, realuAsset] = await Promise.all([ + this.userService.getUsersByUserDataIds(userDataIds), + this.getHolderBalances(), + this.realUnitService.getRealuAsset(), + ]); + + const balances = new Map(userDataIds.map((id) => [id, 0])); + const unresolved = new Set(); + for (const user of users) { + if (!user.address) continue; + + // "Address absent from the holder set" is a genuine 0 (already pre-seeded). An address that IS present but + // carries an unusable balance (e.g. '' from a resyncing indexer) must flow through toShareCount and fail + // closed below — not be swallowed here as a false 0 by a truthiness check. + const raw = holderBalances.get(user.address.toLowerCase()); + if (raw === undefined) continue; + + const userDataId = user.userData.id; + + // A single malformed indexer balance must not blank the whole batch — but it must not be masked as an + // authoritative 0 / undercount either. Any unparsable address makes a member's total unknowable, so fail + // closed for THAT member (-> undefined) while the rest stay correct. + const shares = this.toShareCount(raw, realuAsset.decimals); + if (shares === undefined) { + unresolved.add(userDataId); + continue; + } + + balances.set(userDataId, (balances.get(userDataId) ?? 0) + shares); + } + + // Order-safe: drop poisoned members only at the end, so a later valid address cannot re-add one. + for (const id of unresolved) balances.delete(id); + + return balances; + } catch (e) { + this.logger.warn('Could not resolve REALU balances:', e); + return new Map(); + } + } + + private toShareCount(rawWei: string, decimals: number): number | undefined { + try { + return EvmUtil.fromWeiAmount(rawWei, decimals); + } catch (e) { + this.logger.warn(`Ignoring unparsable REALU holder balance "${rawWei}":`, e); + return undefined; + } + } + + private async getHolderBalances(): Promise> { + return this.holderBalanceCache.get('holders', async () => { + const map = new Map(); + const seenCursors = new Set(); + const maxPages = 100; + + let after: string | undefined; + let complete = false; + for (let i = 0; i < maxPages; i++) { + const page = await this.realUnitService.getHolders(1000, undefined, after); + for (const holder of page.holders) map.set(holder.address.toLowerCase(), holder.balance); + + if (!page.pageInfo?.hasNextPage) { + complete = true; + break; + } + + // A next page without a fresh cursor would silently truncate the sweep and cache the partial set as if + // complete (undercounting real holders). The sibling clients (deuro/juice) return the partial set here; + // balance correctness requires failing closed instead, so the dashboard shows "unknown", not an undercount. + const endCursor = page.pageInfo.endCursor; + if (!endCursor || seenCursors.has(endCursor)) throw new Error('RealUnit holder pagination stalled'); + seenCursors.add(endCursor); + after = endCursor; + } + + // Never cache a partial sweep as authoritative: exhausting the page cap means the full holder set could + // not be read, so treat it as unresolved rather than undercounting. + if (!complete) throw new Error(`RealUnit holder pagination exceeded ${maxPages} pages`); + + // An empty holder set means the indexer could not answer (a resyncing / cold-starting Ponder returns HTTP + // 200 with no accounts): treat it as unresolved rather than caching an authoritative zero — that would + // render real shareholders as a definitive 0 for the whole 5-minute cache window. + if (!map.size) throw new Error('RealUnit indexer returned no holders'); + + return map; + }); + } + // ZIP entry paths must not carry traversal payloads from customer-influenced file names. private sanitizePathComponent(name: string): string { return name.replace(/[\\/]/g, '_').replace(/\.\.+/g, '_'); diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index d6449e00f7..a989c1b195 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -90,6 +90,9 @@ import { import { RealUnitDtoMapper } from './dto/realunit-dto.mapper'; import { AktionariatRegistrationDto, + MAX_SERIALIZED_TIN_LENGTH, + MAX_TAX_RESIDENCES, + MAX_TIN_LENGTH, RealUnitEmailRegistrationDto, RealUnitEmailRegistrationStatus, RealUnitLanguage, @@ -745,7 +748,19 @@ export class RealUnitService { dto.walletAddress, ); if (isForCurrentWallet) { - return this.idempotentRegistrationResult(userData, existingRegistration!, dto.signature); + // Signature check FIRST: idempotentRegistrationResult rejects a mismatching signature with a 400, + // and a request that is about to be rejected must not have mutated the database on its way out. + const status = await this.idempotentRegistrationResult(userData, existingRegistration!, dto.signature); + + // The snapshot follows the event of record, never the request body. countryAndTINs is NOT part of + // the EIP-712 envelope, so a replay could otherwise rewrite user_data.tin to something that was + // never registered with Aktionariat. Syncing from the durable registration row also self-heals a + // retry whose forward succeeded but whose tin audit write failed. + const registrationData = this.toRegistrationDto(existingRegistration!); + if (!registrationData) throw new BadRequestException('Invalid registration data'); + await this.persistUserDataTinAfterRegistration(userData, registrationData); + + return status; } // validate personal data @@ -754,21 +769,25 @@ export class RealUnitService { throw new BadRequestException('Personal data does not match existing data'); } - // save personal data + // Personal data first (first-time only). TINs are intentionally NOT written here: + // user_data.tin is only updated AFTER the registration row is durable so every change + // is recoverable (see persistUserDataTinAfterRegistration). if (!hasExistingData) { await this.userDataService.updatePersonalData(userData, dto.kycData); await this.userDataService.updateUserDataInternal(userData, { nationality: await this.countryService.getCountryWithSymbol(dto.nationality), birthday: new Date(dto.birthday), language: dto.lang && (await this.languageService.getLanguageBySymbol(dto.lang)), - tin: dto.countryAndTINs?.length ? JSON.stringify(dto.countryAndTINs) : undefined, }); } - // forward to Aktionariat (persists the single-source-of-truth registration row in both branches) + // forward to Aktionariat (persists the single-source-of-truth registration row in both branches). + // signedPayload carries countryAndTINs for this event; superseded rows stay queryable. const success = await this.forwardRegistration(userData, dto); if (!success) return RealUnitRegistrationStatus.FORWARDING_FAILED; + await this.persistUserDataTinAfterRegistration(userData, dto); + return RealUnitRegistrationStatus.COMPLETED; } @@ -885,6 +904,125 @@ export class RealUnitService { } } + // Residence country (addressCountry) must appear among the declared tax residences. + // CH is covered ONLY by `swissTaxResidence === true` — never via countryAndTINs (CH has no + // TIN in this contract). Non-CH countries are covered by a countryAndTINs entry with a + // non-empty TIN. Additional tax countries beyond the address country are allowed. + private validateTaxResidenceCoversAddress(dto: RealUnitRegistrationDto): void { + const entries = dto.countryAndTINs ?? []; + + // Defense in depth: the DTO enforces this too, but the service must never index into a non-array. + // A non-array body used to reach here (the DTO's @ValidateIf skipped @IsArray whenever + // swissTaxResidence was true) and crashed with a TypeError => HTTP 500 instead of a 400. + if (!Array.isArray(entries)) { + throw new BadRequestException('countryAndTINs must be an array'); + } + + if (entries.length > MAX_TAX_RESIDENCES) { + throw new BadRequestException(`countryAndTINs must not contain more than ${MAX_TAX_RESIDENCES} entries`); + } + + if (entries.some((e) => e.country === 'CH')) { + throw new BadRequestException( + 'countryAndTINs must not include CH; set swissTaxResidence for Swiss tax residence', + ); + } + + // Nested shape is also enforced here so multi-residence entries stay valid when + // swissTaxResidence is true (DTO @ValidateIf historically skipped nested checks then). + for (const entry of entries) { + if (typeof entry.country !== 'string' || !/^[A-Z]{2}$/.test(entry.country)) { + throw new BadRequestException('countryAndTINs.country must be a 2-letter country code'); + } + if (typeof entry.tin !== 'string' || !entry.tin.trim()) { + throw new BadRequestException('countryAndTINs.tin must be a non-empty string'); + } + if (entry.tin.length > MAX_TIN_LENGTH) { + throw new BadRequestException(`countryAndTINs.tin must not exceed ${MAX_TIN_LENGTH} characters`); + } + } + + const tinCountries = entries.map((e) => e.country); + if (new Set(tinCountries).size !== tinCountries.length) { + throw new BadRequestException('countryAndTINs must not contain duplicate countries'); + } + + if (!dto.swissTaxResidence && entries.length === 0) { + throw new BadRequestException('countryAndTINs is required when swissTaxResidence is false'); + } + + const taxCountries = new Set(tinCountries); + if (dto.swissTaxResidence) taxCountries.add('CH'); + + if (!taxCountries.has(dto.addressCountry)) { + throw new BadRequestException(`Tax residence must include the residence country (${dto.addressCountry})`); + } + + // Fail closed BEFORE anything is forwarded. user_data.tin is a bounded column and is written only + // AFTER forwardRegistration has persisted the registration — an oversized payload must therefore be + // rejected here with a 400, never blow up as a 500 on a registration that is already durable. + const serialized = this.serializeCountryAndTins(entries); + if (serialized && serialized.length > MAX_SERIALIZED_TIN_LENGTH) { + throw new BadRequestException('countryAndTINs is too large'); + } + } + + // Canonical shape for user_data.tin: JSON array of {country, tin}, or null when the + // submission carries no non-CH tax residences (Swiss-only / empty). + private serializeCountryAndTins(entries: { country: string; tin: string }[] | undefined): string | null { + return entries?.length ? JSON.stringify(entries) : null; + } + + // DFX data rule: overwriting a DB value is only allowed when the previous value remains + // recoverable. Tax residences are therefore dual-stored: + // 1) aktionariat_registration.signedPayload — immutable event log (superseded rows kept) + // 2) user_data.tin — current snapshot for queries + // This method runs ONLY after forwardRegistration has persisted (1). It never silently + // destroys a non-null previous tin by writing null (Swiss-only submissions omit + // countryAndTINs from the new payload, so nulling would lose prior foreign TINs). + // Every actual mutation is audited with before→after before the column is updated. + private async persistUserDataTinAfterRegistration(userData: UserData, dto: RealUnitRegistrationDto): Promise { + const previousTin = userData.tin ?? null; + const nextTin = this.serializeCountryAndTins(dto.countryAndTINs); + + if (previousTin === nextTin) return; + + // Never clear a stored TIN set to null — that would drop recoverable history from + // user_data without placing those TINs on the new registration row (Swiss-only payload). + // Prior foreign TINs stay on user_data until a new non-empty set is submitted; each + // registration event remains on aktionariat_registration (active + superseded). + if (nextTin === null && previousTin != null) { + return; + } + + // Fail closed on audit: do not overwrite until the before→after event is written. + await this.logUserDataTinChange(userData.id, dto.walletAddress, previousTin, nextTin); + await this.userDataService.updateUserDataInternal(userData, { tin: nextTin }); + } + + private async logUserDataTinChange( + userDataId: number, + walletAddress: string, + previousTin: string | null, + nextTin: string | null, + ): Promise { + await this.logService.create({ + system: 'RealUnit', + subsystem: 'UserDataTin', + severity: LogSeverity.INFO, + message: JSON.stringify({ + action: 'user_data.tin change', + userDataId, + walletAddress, + previousTin, + nextTin, + changedAt: new Date().toISOString(), + }), + category: String(userDataId), + valid: null, + }); + } + private async validateRegistrationDto(dto: RealUnitRegistrationDto): Promise { // signature validation if (!this.verifyRealUnitRegistrationSignature(dto)) { @@ -903,6 +1041,13 @@ export class RealUnitService { maxAge.setFullYear(maxAge.getFullYear() - 140); if (birthday < maxAge) throw new BadRequestException('Birthday cannot be more than 140 years ago'); + // Tax residence must cover the residence (address) country. `swissTaxResidence` + // counts as CH; each `countryAndTINs` entry covers its country code. Multi- + // residence is allowed (additional countries beyond the address country), but + // the address country itself is mandatory among the declared tax residences — + // e.g. living in DE requires a DE tax-residence entry (with TIN). + this.validateTaxResidenceCoversAddress(dto); + // data validation if (dto.kycData.accountType === AccountType.ORGANIZATION) { if (dto.type !== RealUnitUserType.CORPORATION) { @@ -1143,6 +1288,52 @@ export class RealUnitService { return userDataDto as RealUnitUserDataDto; } + // user_data.tin is written exclusively by persistUserDataTinAfterRegistration, as a JSON array of + // {country, tin} with no CH entry. A malformed or contract-violating legacy value must not take this + // prefill down: GET registration-info is the only channel through which the user can submit a + // CORRECTED declaration, so throwing here would lock the account out of the very fix it needs. The + // bad value is therefore dropped LOUDLY (error log, no PII) and the prefill degrades to "no known tax + // residences" — it never hands the client back a payload that validateTaxResidenceCoversAddress would + // reject. + private parseStoredTin(userData: UserData): { country: string; tin: string }[] { + if (!userData.tin) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(userData.tin); + } catch { + this.logger.error( + `Malformed user_data.tin for userData ${userData.id}: not valid JSON — prefill degraded to empty`, + ); + return []; + } + + if (!Array.isArray(parsed)) { + this.logger.error( + `Malformed user_data.tin for userData ${userData.id}: not an array — prefill degraded to empty`, + ); + return []; + } + + const entries = parsed.filter( + (e): e is { country: string; tin: string } => + !!e && + typeof e.country === 'string' && + /^[A-Z]{2}$/.test(e.country) && + e.country !== 'CH' && + typeof e.tin === 'string' && + !!e.tin.trim(), + ); + + if (entries.length !== parsed.length) { + this.logger.error( + `user_data.tin for userData ${userData.id} contains ${parsed.length - entries.length} entr(ies) violating the registration contract — dropped from the prefill`, + ); + } + + return entries; + } + // Pre-fill source for first-time RealUnit registrations: maps the user's existing DFX KYC data into // the Aktionariat-shaped DTO. The corresponding `completeRegistration` validation // (`isPersonalDataMatching`) compares the submitted KycPersonalData/address against the same @@ -1154,7 +1345,7 @@ export class RealUnitService { const lang = Object.values(RealUnitLanguage).find((l) => l === userData.language?.symbol?.toUpperCase()); const addressStreet = [userData.street, userData.houseNumber].filter((s) => s).join(' '); - const tinEntries: { country: string; tin: string }[] = userData.tin ? JSON.parse(userData.tin) : []; + const tinEntries = this.parseStoredTin(userData); return { email: userData.mail ?? '', @@ -1167,8 +1358,10 @@ export class RealUnitService { addressPostalCode: userData.zip ?? '', addressCity: userData.location ?? '', addressCountry: userData.country?.symbol ?? '', - // Swiss tax residence cannot be derived from KYC data alone; default to the country-of-residence - // signal so a CH-resident pre-fills the common case. The user can still override before signing. + // Default Swiss tax residence from the country-of-residence signal so a CH-resident + // pre-fills the common case. The signed payload must still cover addressCountry among + // the declared tax residences (swissTaxResidence and/or countryAndTINs) — see + // validateTaxResidenceCoversAddress. swissTaxResidence: userData.country?.symbol === 'CH', lang: lang ?? RealUnitLanguage.EN, countryAndTINs: tinEntries.length ? tinEntries : undefined,