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
45 changes: 45 additions & 0 deletions migration/1784600000011-ActivateAssetLedgerAccounts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Activates the ASSET ledger accounts the CoA bootstrap created inactive. The bootstrap derived
* ledger_account.active from Asset.isActive — the OR of the user-tradability flags, which is false for every
* custody/bank asset — so all custody/bank ASSET accounts were created with active=false. Bookings still went
* through (account resolution does not filter on active), but the daily reconciliation, the recon status and the
* unverified-account query all select { type: 'Asset', active: true } and therefore never covered them: drift on
* exactly these accounts could not alarm. The code fix in this PR stops deriving active on creation; this
* migration repairs the rows that already exist.
*
* Scope: every ASSET account backed by an asset row (assetId IS NOT NULL). All of them were created by the
* bootstrap from the CoA universe (custody assets + on-chain assets with a liquidity balance), i.e. they are
* live-booked accounts; none was deactivated deliberately (the feature predates any manual curation). Feedless or
* dead-bank assets are safe to activate: the reconciliation classifies them (NO_FEED / BANK_DEAD / STALE) instead
* of diff-alarming. Rows with a NULL assetId (manual intervention only) are deliberately left untouched.
*
* Idempotent: WHERE "active" = false, a re-run touches nothing.
*
* Verified on a throwaway Postgres 16: flips exactly the inactive ASSET rows with an assetId, leaves active ASSET,
* non-ASSET and NULL-assetId rows untouched, and is a no-op (UPDATE 0) on re-run.
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class ActivateAssetLedgerAccounts1784600000011 {
name = 'ActivateAssetLedgerAccounts1784600000011';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(
`UPDATE "ledger_account" SET "active" = true WHERE "type" = 'Asset' AND "assetId" IS NOT NULL AND "active" = false`,
);
}

async down() {
// no-op: the pre-migration active=false state WAS the defect, and accounts created active=true by the fixed
// bootstrap are indistinguishable from the repaired rows — flipping them back would re-blind reconciliation.
}
};
1 change: 1 addition & 0 deletions src/shared/services/process.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export enum Process {
LEDGER_RECONCILIATION = 'LedgerReconciliation',
LEDGER_MARK_TO_MARKET = 'LedgerMarkToMarket',
LEDGER_CUTOVER = 'LedgerCutover',
LEDGER_COA_BOOTSTRAP = 'LedgerCoaBootstrap',
}

const safetyProcesses: Process[] = [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createMock } from '@golevelup/ts-jest';
import { CronExpression } from '@nestjs/schedule';
import { Test, TestingModule } from '@nestjs/testing';
import { SettingService } from 'src/shared/models/setting/setting.service';
import { Process } from 'src/shared/services/process.service';
Expand All @@ -13,6 +14,7 @@ import { LiquidityOrderDexConsumer } from '../consumers/liquidity-order-dex.cons
import { PayoutOrderConsumer } from '../consumers/payout-order.consumer';
import { TradingOrderConsumer } from '../consumers/trading-order.consumer';
import { getLedgerWatermark, LedgerBookingJobService, setLedgerWatermark } from '../ledger-booking-job.service';
import { LedgerBootstrapService } from '../ledger-bootstrap.service';

describe('LedgerBookingJobService', () => {
let service: LedgerBookingJobService;
Expand All @@ -26,6 +28,7 @@ describe('LedgerBookingJobService', () => {
let liquidityMgmtConsumer: LiquidityMgmtConsumer;
let liquidityOrderDexConsumer: LiquidityOrderDexConsumer;
let tradingOrderConsumer: TradingOrderConsumer;
let bootstrapService: LedgerBootstrapService;

beforeEach(async () => {
settingService = createMock<SettingService>();
Expand All @@ -38,6 +41,7 @@ describe('LedgerBookingJobService', () => {
liquidityMgmtConsumer = createMock<LiquidityMgmtConsumer>();
liquidityOrderDexConsumer = createMock<LiquidityOrderDexConsumer>();
tradingOrderConsumer = createMock<TradingOrderConsumer>();
bootstrapService = createMock<LedgerBootstrapService>();

const module: TestingModule = await Test.createTestingModule({
providers: [
Expand All @@ -52,6 +56,7 @@ describe('LedgerBookingJobService', () => {
{ provide: LiquidityMgmtConsumer, useValue: liquidityMgmtConsumer },
{ provide: LiquidityOrderDexConsumer, useValue: liquidityOrderDexConsumer },
{ provide: TradingOrderConsumer, useValue: tradingOrderConsumer },
{ provide: LedgerBootstrapService, useValue: bootstrapService },
],
}).compile();

Expand Down Expand Up @@ -86,6 +91,7 @@ describe('LedgerBookingJobService', () => {
await service.runLiquidityMgmt();
await service.runLiquidityOrderDex();
await service.runTradingOrder();
await service.runCoaBootstrap();
expect(bankTxConsumer.process).not.toHaveBeenCalled();
expect(exchangeTxConsumer.process).not.toHaveBeenCalled();
expect(cryptoInputConsumer.process).not.toHaveBeenCalled();
Expand All @@ -95,6 +101,7 @@ describe('LedgerBookingJobService', () => {
expect(liquidityMgmtConsumer.process).not.toHaveBeenCalled();
expect(liquidityOrderDexConsumer.process).not.toHaveBeenCalled();
expect(tradingOrderConsumer.process).not.toHaveBeenCalled();
expect(bootstrapService.bootstrap).not.toHaveBeenCalled();
});

it('runs the consumers once the ledger is ready', async () => {
Expand All @@ -108,6 +115,7 @@ describe('LedgerBookingJobService', () => {
await service.runLiquidityMgmt();
await service.runLiquidityOrderDex();
await service.runTradingOrder();
await service.runCoaBootstrap();
expect(bankTxConsumer.process).toHaveBeenCalledTimes(1);
expect(exchangeTxConsumer.process).toHaveBeenCalledTimes(1);
expect(cryptoInputConsumer.process).toHaveBeenCalledTimes(1);
Expand All @@ -117,31 +125,42 @@ describe('LedgerBookingJobService', () => {
expect(liquidityMgmtConsumer.process).toHaveBeenCalledTimes(1);
expect(liquidityOrderDexConsumer.process).toHaveBeenCalledTimes(1);
expect(tradingOrderConsumer.process).toHaveBeenCalledTimes(1);
expect(bootstrapService.bootstrap).toHaveBeenCalledTimes(1);
});
});

describe('@DfxCron kill-switch (Hard Constraint #5, Minor R9-2)', () => {
// every registered cron method must carry a Process.LEDGER_BOOKING_* flag (no silent no-guard cron)
const expectedFlags: Record<string, Process> = {
runBankTx: Process.LEDGER_BOOKING_BANK_TX,
runExchangeTx: Process.LEDGER_BOOKING_EXCHANGE_TX,
runCryptoInput: Process.LEDGER_BOOKING_CRYPTO_INPUT,
runPayoutOrder: Process.LEDGER_BOOKING_PAYOUT,
runBuyCrypto: Process.LEDGER_BOOKING_BUY_CRYPTO,
runBuyFiat: Process.LEDGER_BOOKING_BUY_FIAT,
runLiquidityMgmt: Process.LEDGER_BOOKING_LIQUIDITY_MANAGEMENT,
runLiquidityOrderDex: Process.LEDGER_BOOKING_LIQUIDITY_ORDER,
runTradingOrder: Process.LEDGER_BOOKING_TRADING_ORDER,
describe('@DfxCron kill-switch + schedule (Hard Constraint #5, Minor R9-2)', () => {
// every registered cron method must carry a Process flag (no silent no-guard cron) AND its intended schedule:
// the nine booking wrappers run EVERY_MINUTE, only the CoA bootstrap runs EVERY_5_MINUTES
const expectedCrons: Record<string, { process: Process; expression: CronExpression }> = {
runBankTx: { process: Process.LEDGER_BOOKING_BANK_TX, expression: CronExpression.EVERY_MINUTE },
runExchangeTx: { process: Process.LEDGER_BOOKING_EXCHANGE_TX, expression: CronExpression.EVERY_MINUTE },
runCryptoInput: { process: Process.LEDGER_BOOKING_CRYPTO_INPUT, expression: CronExpression.EVERY_MINUTE },
runPayoutOrder: { process: Process.LEDGER_BOOKING_PAYOUT, expression: CronExpression.EVERY_MINUTE },
runBuyCrypto: { process: Process.LEDGER_BOOKING_BUY_CRYPTO, expression: CronExpression.EVERY_MINUTE },
runBuyFiat: { process: Process.LEDGER_BOOKING_BUY_FIAT, expression: CronExpression.EVERY_MINUTE },
runLiquidityMgmt: {
process: Process.LEDGER_BOOKING_LIQUIDITY_MANAGEMENT,
expression: CronExpression.EVERY_MINUTE,
},
runLiquidityOrderDex: {
process: Process.LEDGER_BOOKING_LIQUIDITY_ORDER,
expression: CronExpression.EVERY_MINUTE,
},
runTradingOrder: { process: Process.LEDGER_BOOKING_TRADING_ORDER, expression: CronExpression.EVERY_MINUTE },
runCoaBootstrap: { process: Process.LEDGER_COA_BOOTSTRAP, expression: CronExpression.EVERY_5_MINUTES },
};

for (const [method, flag] of Object.entries(expectedFlags)) {
it(`${method} carries its own ${flag} process flag`, () => {
for (const [method, expected] of Object.entries(expectedCrons)) {
it(`${method} carries its own ${expected.process} process flag, its schedule and the lock timeout`, () => {
const params: DfxCronParams = Reflect.getMetadata(
DFX_CRONJOB_PARAMS,
LedgerBookingJobService.prototype[method as keyof LedgerBookingJobService],
);
expect(params).toBeDefined();
expect(params.process).toBe(flag);
expect(params.process).toBe(expected.process);
expect(params.expression).toBe(expected.expression);
expect(params.timeout).toBe(1800);
});
}
});
Expand Down
Loading
Loading