Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions migration/1779381590531-RouteScryptEurViaUsdt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Stop buying BTC directly on Scrypt. Convert incoming EUR on Scrypt to USDT instead
// and route USDT to Binance for further BTC acquisition (same pattern as CHF / Rule 312).
//
// Reverts the routing introduced by migration 1774100000000-AddScryptSellIfDeficitAction.js:
// 1. Re-points Rule 313 (Scrypt/EUR redundancy) from Action 261 back to Action 233
// (Scrypt sell USDT) — already in use by Rule 312 (CHF) and known good.
// 2. Removes Action 261 (Scrypt sell-if-deficit BTC), which is no longer referenced.
//
// Verified on 2026-05-21: no other action references 261 via onSuccessId/onFailId.
// Rule 314 (Scrypt/BTC withdraw) is kept as cleanup path for any residual BTC on Scrypt.
module.exports = class RouteScryptEurViaUsdt1779381590531 {
name = 'RouteScryptEurViaUsdt1779381590531';

async up(queryRunner) {
// Skip if rule 313 or action 233 don't exist in this environment
const [rule] = await queryRunner.query(`SELECT "id" FROM "liquidity_management_rule" WHERE "id" = 313`);
if (!rule) return;

const [fallbackAction] = await queryRunner.query(
`SELECT "id" FROM "liquidity_management_action" WHERE "id" = 233`,
);
if (!fallbackAction) return;

// 1. Point Rule 313 (Scrypt/EUR redundancy) at the USDT sell action
await queryRunner.query(`UPDATE "liquidity_management_rule" SET "redundancyStartActionId" = 233 WHERE "id" = 313`);

// 2. Remove the now-unreferenced sell-if-deficit BTC action
await queryRunner.query(
`DELETE FROM "liquidity_management_action" WHERE "id" = 261 AND "system" = 'Scrypt' AND "command" = 'sell-if-deficit'`,
);
}

async down(queryRunner) {
// Restore Action 261 (Scrypt sell-if-deficit BTC) and re-point Rule 313 at it.
const [fallbackAction] = await queryRunner.query(
`SELECT "id" FROM "liquidity_management_action" WHERE "id" = 233`,
);
if (!fallbackAction) return;

await queryRunner.query(
`INSERT INTO "liquidity_management_action" ("id", "system", "command", "tag", "params", "onSuccessId", "onFailId") VALUES (261, 'Scrypt', 'sell-if-deficit', 'SCRYPT BTC', '{"tradeAsset":"BTC","checkAssetId":113}', NULL, 233)`,
);

await queryRunner.query(`UPDATE "liquidity_management_rule" SET "redundancyStartActionId" = 261 WHERE "id" = 313`);
}
};
36 changes: 36 additions & 0 deletions migration/1779812989037-FixNullableUniqueIndexes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class FixNullableUniqueIndexes1779812989037 {
name = 'FixNullableUniqueIndexes1779812989037'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
// kyc_step: recreate unique index with NULLS NOT DISTINCT so NULL type is treated as equal
await queryRunner.query(`DROP INDEX "IDX_3a1150791476264753a67212a1"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_3a1150791476264753a67212a1" ON "kyc_step" ("userDataId", "name", "type", "sequenceNumber") NULLS NOT DISTINCT`);

// user_data: recreate unique index with NULLS NOT DISTINCT so NULL nationality is treated as equal
await queryRunner.query(`DROP INDEX "IDX_99da8fce0c522a35d93b9499f4"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_99da8fce0c522a35d93b9499f4" ON "user_data" ("identDocumentId", "nationalityId", "accountType", "kycType") NULLS NOT DISTINCT WHERE "identDocumentId" IS NOT NULL AND "accountType" IS NOT NULL AND "kycType" IS NOT NULL`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "IDX_99da8fce0c522a35d93b9499f4"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_99da8fce0c522a35d93b9499f4" ON "user_data" ("identDocumentId", "nationalityId", "accountType", "kycType") WHERE "identDocumentId" IS NOT NULL AND "accountType" IS NOT NULL AND "kycType" IS NOT NULL`);

await queryRunner.query(`DROP INDEX "IDX_3a1150791476264753a67212a1"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_3a1150791476264753a67212a1" ON "kyc_step" ("userDataId", "name", "type", "sequenceNumber")`);
}
}
30 changes: 30 additions & 0 deletions migration/1780071506610-AddPayoutOrderRetryTracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddPayoutOrderRetryTracking1780071506610 {
name = 'AddPayoutOrderRetryTracking1780071506610'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "payout_order" ADD "retryCount" integer NOT NULL DEFAULT 0`);
await queryRunner.query(`ALTER TABLE "payout_order" ADD "lastError" character varying(2048)`);
await queryRunner.query(`ALTER TABLE "payout_order" ADD "lastAttemptDate" TIMESTAMP`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "payout_order" DROP COLUMN "lastAttemptDate"`);
await queryRunner.query(`ALTER TABLE "payout_order" DROP COLUMN "lastError"`);
await queryRunner.query(`ALTER TABLE "payout_order" DROP COLUMN "retryCount"`);
}
}
66 changes: 66 additions & 0 deletions src/shared/utils/__tests__/migration-psql-check.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as fs from 'fs';
import * as path from 'path';

/**
* Scans migration files for MSSQL-specific syntax that won't work on PostgreSQL.
*
* Since the PSQL migration (#3620), all new migrations must use PostgreSQL syntax.
* This test catches common MSSQL patterns that would fail silently or break on deploy.
*/
describe('Migration PostgreSQL Compatibility', () => {
const migrationDir = path.join(__dirname, '..', '..', '..', '..', 'migration');

// Migrations written before the PSQL migration are exempt
const psqlMigrationTimestamp = 1779802432879; // AddForeignKeyIndexes (last known PSQL migration)

const mssqlPatterns: { pattern: RegExp; description: string }[] = [
{ pattern: /"dbo"\./i, description: 'MSSQL schema prefix "dbo."' },
{ pattern: /IDENTITY_INSERT/i, description: 'MSSQL IDENTITY_INSERT' },
{ pattern: /\bTOP\s+\d+/i, description: 'MSSQL TOP N (use LIMIT instead)' },
{ pattern: /\bNVARCHAR\b/i, description: 'MSSQL NVARCHAR (use VARCHAR or TEXT instead)' },
{ pattern: /\bDATETIME2\b/i, description: 'MSSQL DATETIME2 (use TIMESTAMP instead)' },
{ pattern: /\bGETDATE\s*\(\)/i, description: 'MSSQL GETDATE() (use NOW() instead)' },
{ pattern: /\bIDENTITY\s*\(\s*\d+\s*,\s*\d+\s*\)/i, description: 'MSSQL IDENTITY(1,1) (use SERIAL instead)' },
{ pattern: /\[(\w+)\]/g, description: 'MSSQL bracket quoting [column] (use "column" instead)' },
];

const getMigrationFiles = (): { name: string; content: string; timestamp: number }[] => {
if (!fs.existsSync(migrationDir)) return [];

return fs
.readdirSync(migrationDir)
.filter((f) => f.endsWith('.js') && /^\d+/.test(f))
.map((f) => {
const timestamp = parseInt(f.split('-')[0], 10);
return {
name: f,
content: fs.readFileSync(path.join(migrationDir, f), 'utf-8'),
timestamp,
};
})
.filter((f) => f.timestamp > psqlMigrationTimestamp);
};

it('should not contain MSSQL syntax in new migrations', () => {
const files = getMigrationFiles();
const issues: string[] = [];

for (const file of files) {
for (const { pattern, description } of mssqlPatterns) {
// Reset lastIndex for global patterns
pattern.lastIndex = 0;

if (pattern.test(file.content)) {
issues.push(` ${file.name}: ${description}`);
}
}
}

if (issues.length > 0) {
throw new Error(
`Found MSSQL syntax in post-PSQL migrations:\n${issues.join('\n')}\n\n` +
`All migrations after the PSQL migration must use PostgreSQL syntax.`,
);
}
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Inject, Injectable, forwardRef } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum';
import { ScryptOrderInfo, ScryptOrderSide, ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto';
import { TradeChangedException } from 'src/integration/exchange/exceptions/trade-changed.exception';
Expand All @@ -8,28 +8,20 @@ import { Asset } 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 { Util } from 'src/shared/utils/util';
import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service';
import { DexService } from 'src/subdomains/supporting/dex/services/dex.service';
import {
PriceCurrency,
PriceValidity,
PricingService,
} from 'src/subdomains/supporting/pricing/services/pricing.service';
import { PriceValidity, PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service';
import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity';
import { LiquidityManagementSystem } from '../../enums';
import { OrderFailedException } from '../../exceptions/order-failed.exception';
import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception';
import { Command, CorrelationId } from '../../interfaces';
import { LiquidityBalanceRepository } from '../../repositories/liquidity-balance.repository';
import { LiquidityManagementOrderRepository } from '../../repositories/liquidity-management-order.repository';
import { LiquidityManagementRuleRepository } from '../../repositories/liquidity-management-rule.repository';
import { LiquidityActionAdapter } from './base/liquidity-action.adapter';

export enum ScryptAdapterCommands {
WITHDRAW = 'withdraw',
SELL = 'sell',
BUY = 'buy',
SELL_IF_DEFICIT = 'sell-if-deficit',
}

@Injectable()
Expand All @@ -44,16 +36,12 @@ export class ScryptAdapter extends LiquidityActionAdapter {
private readonly orderRepo: LiquidityManagementOrderRepository,
private readonly pricingService: PricingService,
private readonly assetService: AssetService,
private readonly ruleRepo: LiquidityManagementRuleRepository,
private readonly balanceRepo: LiquidityBalanceRepository,
@Inject(forwardRef(() => BuyCryptoService)) private readonly buyCryptoService: BuyCryptoService,
) {
super(LiquidityManagementSystem.SCRYPT);

this.commands.set(ScryptAdapterCommands.WITHDRAW, this.withdraw.bind(this));
this.commands.set(ScryptAdapterCommands.SELL, this.sell.bind(this));
this.commands.set(ScryptAdapterCommands.BUY, this.buy.bind(this));
this.commands.set(ScryptAdapterCommands.SELL_IF_DEFICIT, this.sellIfDeficit.bind(this));
}

async checkCompletion(order: LiquidityManagementOrder): Promise<boolean> {
Expand All @@ -67,9 +55,6 @@ export class ScryptAdapter extends LiquidityActionAdapter {
case ScryptAdapterCommands.BUY:
return this.checkBuyCompletion(order);

case ScryptAdapterCommands.SELL_IF_DEFICIT:
return this.checkSellCompletion(order);

default:
return false;
}
Expand All @@ -84,9 +69,6 @@ export class ScryptAdapter extends LiquidityActionAdapter {
case ScryptAdapterCommands.BUY:
return this.validateTradeParams(params);

case ScryptAdapterCommands.SELL_IF_DEFICIT:
return this.validateSellIfDeficitParams(params);

default:
throw new Error(`Command ${command} not supported by ScryptAdapter`);
}
Expand Down Expand Up @@ -128,6 +110,14 @@ export class ScryptAdapter extends LiquidityActionAdapter {
private async sell(order: LiquidityManagementOrder): Promise<CorrelationId> {
const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(order.action.paramMap);

// Structural guard: Scrypt BTC/EUR (and other Scrypt BTC pairs) have materially worse spreads
// than Scrypt USDT pairs. BTC acquisition on Scrypt is no longer supported — route via Binance USDT.
if (tradeAsset === 'BTC') {
throw new OrderNotProcessableException(
'Scrypt: buying BTC is no longer supported (tradeAsset=BTC). Route via Binance USDT instead.',
);
}

const targetAsset = order.pipeline.rule.targetAsset;
const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`);

Expand All @@ -151,6 +141,15 @@ export class ScryptAdapter extends LiquidityActionAdapter {
const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(order.action.paramMap);

const targetAssetEntity = order.pipeline.rule.targetAsset;

// Structural guard: Scrypt BTC pairs have materially worse spreads than USDT pairs.
// BTC acquisition on Scrypt is no longer supported — route via Binance USDT instead.
if (targetAssetEntity.dexName === 'BTC') {
throw new OrderNotProcessableException(
'Scrypt: buying BTC is no longer supported (targetAsset=BTC). Route via Binance USDT instead.',
);
}

const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`);

const price = await this.getAndCheckTradePrice(tradeAssetEntity, targetAssetEntity, maxPriceDeviation);
Expand Down Expand Up @@ -184,57 +183,6 @@ export class ScryptAdapter extends LiquidityActionAdapter {
}
}

private async sellIfDeficit(order: LiquidityManagementOrder): Promise<CorrelationId> {
const { tradeAsset, checkAssetId, maxPriceDeviation } = this.parseSellIfDeficitParams(order.action.paramMap);

// Check if the referenced asset has a deficit or pending liquidity demand
const checkRule = await this.ruleRepo.findOneBy({ targetAsset: { id: checkAssetId } });
if (!checkRule) {
throw new OrderNotProcessableException(`No rule found for asset ${checkAssetId}`);
}

const checkAsset = checkRule.targetAsset;
const checkBalance = await this.balanceRepo.findOneBy({ asset: { id: checkAssetId } });

// Convert pending demand from CHF to check asset
const pendingDemandChf = await this.buyCryptoService.getPendingLiquidityDemandChf(checkAssetId);
const chfPrice = await this.pricingService.getPrice(PriceCurrency.CHF, checkAsset, PriceValidity.VALID_ONLY);
const pendingDemand = chfPrice.convert(pendingDemandChf, 8);

const effectiveBalance = (checkBalance?.amount ?? 0) - pendingDemand;
const hasDeficit = effectiveBalance < (checkRule.minimal ?? 0);

if (!hasDeficit) {
throw new OrderNotProcessableException(
`No deficit for asset ${checkAssetId} (balance: ${checkBalance?.amount}, pending: ${pendingDemand}, effective: ${effectiveBalance}, minimal: ${checkRule.minimal})`,
);
}

// Calculate how much of the trade asset is needed to reach optimal (accounting for pending demand)
const deficitAmount = (checkRule.optimal ?? 0) - effectiveBalance;
if (deficitAmount <= 0) {
throw new OrderNotProcessableException(`No deficit to optimal for asset ${checkAssetId}`);
}

const targetAsset = order.pipeline.rule.targetAsset;
const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`);

const price = await this.getAndCheckTradePrice(targetAsset, tradeAssetEntity, maxPriceDeviation);
const availableBalance = await this.scryptService.getAvailableBalance(targetAsset.dexName);

// price = targetAsset per tradeAsset (e.g., EUR per BTC)
const sellAmount = Util.floor(deficitAmount * price, 6);
const amount = Util.floor(Math.min(sellAmount, order.maxAmount, availableBalance), 6);

if (amount <= 0) {
throw new OrderNotProcessableException(
`Scrypt: insufficient amount for sell-if-deficit (needed: ${sellAmount}, available: ${availableBalance})`,
);
}

return this.executeSell(order, amount, targetAsset.dexName, tradeAsset);
}

// --- COMPLETION CHECKS --- //

private async checkWithdrawCompletion(order: LiquidityManagementOrder): Promise<boolean> {
Expand Down Expand Up @@ -389,30 +337,6 @@ export class ScryptAdapter extends LiquidityActionAdapter {
return { tradeAsset, maxPriceDeviation };
}

private validateSellIfDeficitParams(params: Record<string, unknown>): boolean {
try {
this.parseSellIfDeficitParams(params);
return true;
} catch {
return false;
}
}

private parseSellIfDeficitParams(params: Record<string, unknown>): {
tradeAsset: string;
checkAssetId: number;
maxPriceDeviation?: number;
} {
const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(params);
const checkAssetId = params.checkAssetId as number | undefined;

if (!checkAssetId) {
throw new Error('Params provided to ScryptAdapter sell-if-deficit command are missing checkAssetId.');
}

return { tradeAsset, checkAssetId, maxPriceDeviation };
}

// --- HELPER METHODS --- //

private async executeSell(
Expand Down
8 changes: 5 additions & 3 deletions src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Util } from 'src/shared/utils/util';
import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity';
import { UserData } from '../../../user/models/user-data/user-data.entity';
import { KycStep } from '../../entities/kyc-step.entity';
import { KycStepName } from '../../enums/kyc-step-name.enum';
import { KycStepName, KycStepRepeatable } from '../../enums/kyc-step-name.enum';
import { KycStepType, getKycStepIndex, getKycTypeIndex, requiredKycSteps } from '../../enums/kyc.enum';
import { ReviewStatus } from '../../enums/review-status.enum';
import { KycLevelDto, KycProcessStatus, KycSessionDto } from '../output/kyc-info.dto';
Expand Down Expand Up @@ -110,8 +110,10 @@ export class KycInfoMapper {
}, new Map<string, KycStep[]>());

const visibleSteps = Array.from(groupedSteps.values()).map((steps) => {
const completedSteps = steps.filter((s) => s.isCompleted);
return Util.maxObj(completedSteps.length ? completedSteps : steps, 'sequenceNumber');
const completedAndInProgressSteps = steps.filter(
(s) => s.isCompleted || (s.isInProgress && KycStepRepeatable.includes(s.name)),
);
return Util.maxObj(completedAndInProgressSteps.length ? completedAndInProgressSteps : steps, 'sequenceNumber');
});

return visibleSteps.sort((a, b) => {
Expand Down
Loading
Loading