From 30fb270f4fc7a7603f9243381e8b027a0d4b4b8e Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 14 Jul 2026 18:02:22 +0200 Subject: [PATCH 1/4] feat(referral): add permanent Denario ref alias --- .../1784037000000-AddDenarioPermanentRef.js | 140 +++++++++++ src/app.controller.spec.ts | 64 ++++- src/app.controller.ts | 2 +- ...dd-denario-permanent-ref.migration.spec.ts | 233 ++++++++++++++++++ 4 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 migration/1784037000000-AddDenarioPermanentRef.js create mode 100644 src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts diff --git a/migration/1784037000000-AddDenarioPermanentRef.js b/migration/1784037000000-AddDenarioPermanentRef.js new file mode 100644 index 0000000000..326849b69a --- /dev/null +++ b/migration/1784037000000-AddDenarioPermanentRef.js @@ -0,0 +1,140 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +const REF_SETTING_KEY = 'ref-keys'; +const DENARIO_ALIAS = 'denario'; +const DENARIO_ORGANIZATION_NAMES = ['denario', 'denario ag']; +const REF_CODE_FORMAT = /^\w{1,3}-\w{1,3}$/; + +/** + * Parse the ref-keys setting without silently replacing corrupt configuration. + * + * @param {{ value: string } | undefined} row + * @returns {Record} + */ +function parseRefKeys(row) { + if (!row) return {}; + + let refKeys; + try { + refKeys = JSON.parse(row.value); + } catch { + throw new Error(`Setting '${REF_SETTING_KEY}' does not contain valid JSON`); + } + + if (!refKeys || typeof refKeys !== 'object' || Array.isArray(refKeys)) { + throw new Error(`Setting '${REF_SETTING_KEY}' must contain a JSON object`); + } + + return refKeys; +} + +/** + * Resolve the environment-specific referral code from the Denario organization account. + * Production and development assign referral codes independently, so persisting one numeric + * code in source would inevitably point at the wrong account in one of the environments. + * + * @param {QueryRunner} queryRunner + * @returns {Promise} + */ +async function resolveDenarioRef(queryRunner) { + const rows = await queryRunner.query( + ` + SELECT DISTINCT u."ref" AS "ref" + FROM "user" u + INNER JOIN "user_data" ud ON ud."id" = u."userDataId" + WHERE LOWER(BTRIM(ud."organizationName")) IN ($1, $2) + AND ud."accountType" = 'Organization' + AND ud."status" = 'Active' + AND u."status" = 'Active' + AND u."ref" IS NOT NULL + ORDER BY u."ref" + `, + DENARIO_ORGANIZATION_NAMES, + ); + + const refs = rows.map((row) => row.ref); + if (refs.length === 0) { + throw new Error( + 'No active Denario organization user with a referral code was found. ' + + 'Create and activate the Denario account in this environment before running the migration.', + ); + } + if (refs.length > 1) { + throw new Error(`Denario referral target is ambiguous: found ${refs.length} active referral codes`); + } + const denarioRef = refs.at(0); + if (!REF_CODE_FORMAT.test(denarioRef)) { + throw new Error(`Denario account has an invalid referral code: '${denarioRef}'`); + } + + return denarioRef; +} + +/** + * Adds the permanent `denario` alias to the environment-local `ref-keys` setting. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddDenarioPermanentRef1784037000000 { + name = 'AddDenarioPermanentRef1784037000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + const denarioRef = await resolveDenarioRef(queryRunner); + const row = ( + await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = $1 FOR UPDATE`, Array.of(REF_SETTING_KEY)) + ).at(0); + const refKeys = parseRefKeys(row); + + if (Object.prototype.hasOwnProperty.call(refKeys, DENARIO_ALIAS)) { + if (Reflect.get(refKeys, DENARIO_ALIAS) === denarioRef) return; + throw new Error( + `Setting '${REF_SETTING_KEY}' already contains a conflicting '${DENARIO_ALIAS}' alias; refusing to overwrite it`, + ); + } + + Reflect.set(refKeys, DENARIO_ALIAS, denarioRef); + const value = JSON.stringify(refKeys); + + if (row) { + await queryRunner.query(`UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, [ + value, + REF_SETTING_KEY, + ]); + } else { + await queryRunner.query( + `INSERT INTO "setting" ("key", "value", "created", "updated") VALUES ($1, $2, NOW(), NOW())`, + [REF_SETTING_KEY, value], + ); + } + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + const row = ( + await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = $1 FOR UPDATE`, Array.of(REF_SETTING_KEY)) + ).at(0); + if (!row) return; + + const refKeys = parseRefKeys(row); + if (!Object.prototype.hasOwnProperty.call(refKeys, DENARIO_ALIAS)) return; + + Reflect.deleteProperty(refKeys, DENARIO_ALIAS); + if (Object.keys(refKeys).length === 0) { + await queryRunner.query(`DELETE FROM "setting" WHERE "key" = $1`, Array.of(REF_SETTING_KEY)); + } else { + await queryRunner.query(`UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, [ + JSON.stringify(refKeys), + REF_SETTING_KEY, + ]); + } + } +}; diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index eddf59be5e..2b13faa21b 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -1,19 +1,28 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { Request, Response } from 'express'; + +jest.mock('./config/config', () => ({ Config: { formats: { ref: /^(\w{1,3}-\w{1,3})$/ } } })); + import { AppController } from './app.controller'; import { SettingService } from './shared/models/setting/setting.service'; -import { HttpService } from './shared/services/http.service'; import { RefService } from './subdomains/core/referral/process/ref.service'; describe('AppController', () => { let controller: AppController; + let addOrUpdate: jest.Mock; + let getSetting: jest.Mock; + let response: Pick; beforeEach(async () => { + addOrUpdate = jest.fn(); + getSetting = jest.fn().mockResolvedValue({ denario: '123-456' }); + response = { redirect: jest.fn() }; + const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], providers: [ - { provide: RefService, useValue: {} }, - { provide: HttpService, useValue: {} }, - { provide: SettingService, useValue: {} }, + { provide: RefService, useValue: { addOrUpdate } }, + { provide: SettingService, useValue: { getObj: getSetting } }, ], }).compile(); @@ -23,4 +32,51 @@ describe('AppController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('referral redirects', () => { + it('stores the resolved permanent alias on the generic app route', async () => { + await controller.createRefNew('192.0.2.1', 'denario', undefined, response as Response); + + expect(getSetting).toHaveBeenCalledWith('ref-keys', {}); + expect(addOrUpdate).toHaveBeenCalledWith('192.0.2.1', '123-456', undefined); + expect(response.redirect).toHaveBeenCalledWith(307, 'https://dfx.swiss/'); + }); + + it('stores the resolved permanent alias on the services route', async () => { + await controller.redirectToStore( + '192.0.2.2', + 'services' as never, + 'denario', + undefined, + { headers: {} } as Request, + response as Response, + ); + + expect(addOrUpdate).toHaveBeenCalledWith('192.0.2.2', '123-456', undefined); + expect(response.redirect).toHaveBeenCalledWith(303, 'https://app.dfx.swiss/'); + }); + + it('keeps direct referral codes unchanged', async () => { + await controller.createRefNew('192.0.2.3', '654-321', undefined, response as Response); + + expect(addOrUpdate).toHaveBeenCalledWith('192.0.2.3', '654-321', undefined); + }); + + it('does not persist an unknown alias without an origin', async () => { + getSetting.mockResolvedValue({}); + + await controller.createRefNew('192.0.2.4', 'unknown', undefined, response as Response); + + expect(addOrUpdate).not.toHaveBeenCalled(); + expect(response.redirect).toHaveBeenCalledWith(307, 'https://dfx.swiss/'); + }); + + it('persists an origin even when no referral alias resolves', async () => { + getSetting.mockResolvedValue({}); + + await controller.createRefNew('192.0.2.5', 'unknown', 'denario-app', response as Response); + + expect(addOrUpdate).toHaveBeenCalledWith('192.0.2.5', undefined, 'denario-app'); + }); + }); }); diff --git a/src/app.controller.ts b/src/app.controller.ts index 7be3f5405c..a4f7f72a0d 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -136,7 +136,7 @@ export class AppController { @Res() res: Response, ): Promise { const ref = await this.getRef(code); - if (ref || origin) await this.refService.addOrUpdate(ip, code, origin); + if (ref || origin) await this.refService.addOrUpdate(ip, ref, origin); res.redirect(307, this.homepageUrl); } diff --git a/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts b/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts new file mode 100644 index 0000000000..71f514c20d --- /dev/null +++ b/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts @@ -0,0 +1,233 @@ +import { DataType, newDb } from 'pg-mem'; +import { DataSource, QueryRunner } from 'typeorm'; + +type Migration = { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +type MigrationHarness = { + queryRunner: QueryRunner; + query: jest.Mock; + getSetting(): string | undefined; +}; + +let AddDenarioPermanentRef: new () => Migration; + +function createHarness(refs: unknown[] = ['123-456'], initialSetting?: string): MigrationHarness { + let setting = initialSetting; + const query = jest.fn(async (sql: string, parameters: unknown[] = []) => { + if (sql.includes('FROM "user" u')) return refs.map((ref) => ({ ref })); + if (sql.startsWith('SELECT "value" FROM "setting"')) return setting === undefined ? [] : [{ value: setting }]; + if (sql.startsWith('UPDATE "setting"')) { + setting = parameters[0] as string; + return []; + } + if (sql.startsWith('INSERT INTO "setting"')) { + setting = parameters[1] as string; + return []; + } + if (sql.startsWith('DELETE FROM "setting"')) { + setting = undefined; + return []; + } + + throw new Error(`Unexpected query: ${sql}`); + }); + + return { + queryRunner: { query } as unknown as QueryRunner, + query, + getSetting: () => setting, + }; +} + +describe('AddDenarioPermanentRef migration', () => { + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddDenarioPermanentRef = require('../../../../../migration/1784037000000-AddDenarioPermanentRef'); + }); + + it('adds the environment-specific Denario ref while preserving existing aliases', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222', eternl: '222-333' })); + + await new AddDenarioPermanentRef().up(harness.queryRunner); + + expect(JSON.parse(harness.getSetting()!)).toEqual({ + cakewallet: '111-222', + eternl: '222-333', + denario: '123-456', + }); + expect(harness.query).toHaveBeenCalledWith(expect.stringContaining('LOWER(BTRIM(ud."organizationName"))'), [ + 'denario', + 'denario ag', + ]); + }); + + it('creates ref-keys when the setting does not exist', async () => { + const harness = createHarness(); + + await new AddDenarioPermanentRef().up(harness.queryRunner); + + expect(JSON.parse(harness.getSetting()!)).toEqual({ denario: '123-456' }); + expect(harness.query).toHaveBeenCalledWith(expect.stringMatching(/^INSERT INTO "setting"/), [ + 'ref-keys', + JSON.stringify({ denario: '123-456' }), + ]); + }); + + it('is idempotent when the alias already targets the same account', async () => { + const initialSetting = JSON.stringify({ denario: '123-456' }); + const harness = createHarness(['123-456'], initialSetting); + + await new AddDenarioPermanentRef().up(harness.queryRunner); + + expect(harness.getSetting()).toBe(initialSetting); + expect(harness.query).toHaveBeenCalledTimes(2); + }); + + it('refuses to overwrite a conflicting Denario alias', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ denario: '999-999' })); + + await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow('conflicting'); + expect(JSON.parse(harness.getSetting()!)).toEqual({ denario: '999-999' }); + }); + + it.each([ + { refs: [], error: 'No active Denario organization user' }, + { refs: ['123-456', '234-567'], error: 'ambiguous' }, + { refs: ['1234-56'], error: 'invalid referral code' }, + ])('rejects an unusable target: $error', async ({ refs, error }) => { + const harness = createHarness(refs); + + await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow(error); + expect(harness.getSetting()).toBeUndefined(); + }); + + it.each(['not-json', '[]', 'null'])('rejects corrupt ref-keys configuration: %s', async (value) => { + const harness = createHarness(['123-456'], value); + + await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow("Setting 'ref-keys'"); + expect(harness.getSetting()).toBe(value); + }); + + it('removes only the Denario alias on rollback', async () => { + const harness = createHarness([], JSON.stringify({ cakewallet: '111-222', denario: '123-456' })); + + await new AddDenarioPermanentRef().down(harness.queryRunner); + + expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222' }); + }); + + it('removes an otherwise empty ref-keys setting on rollback', async () => { + const harness = createHarness([], JSON.stringify({ denario: '123-456' })); + + await new AddDenarioPermanentRef().down(harness.queryRunner); + + expect(harness.getSetting()).toBeUndefined(); + }); + + it.each([undefined, JSON.stringify({ cakewallet: '111-222' })])( + 'leaves unrelated configuration untouched on rollback: %s', + async (value) => { + const harness = createHarness([], value); + + await new AddDenarioPermanentRef().down(harness.queryRunner); + + expect(harness.getSetting()).toBe(value); + expect(harness.query).toHaveBeenCalledTimes(1); + }, + ); +}); + +describe('AddDenarioPermanentRef migration (postgres semantics)', () => { + let dataSource: DataSource; + + beforeAll(async () => { + const db = newDb(); + db.public.registerFunction({ name: 'version', returns: DataType.text, implementation: () => 'PostgreSQL 15.0' }); + db.public.registerFunction({ name: 'current_database', returns: DataType.text, implementation: () => 'test' }); + db.public.registerFunction({ + name: 'btrim', + args: [DataType.text], + returns: DataType.text, + implementation: (value: string) => value.trim(), + }); + + dataSource = (await db.adapters.createTypeormDataSource({ + type: 'postgres', + entities: [], + })) as DataSource; + await dataSource.initialize(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + beforeEach(async () => { + await dataSource.query(`DROP TABLE IF EXISTS "setting"`); + await dataSource.query(`DROP TABLE IF EXISTS "user"`); + await dataSource.query(`DROP TABLE IF EXISTS "user_data"`); + await dataSource.query(` + CREATE TABLE "user_data" ( + "id" integer PRIMARY KEY, + "organizationName" text, + "accountType" text NOT NULL, + "status" text NOT NULL + ) + `); + await dataSource.query(` + CREATE TABLE "user" ( + "id" integer PRIMARY KEY, + "userDataId" integer NOT NULL, + "status" text NOT NULL, + "ref" text + ) + `); + await dataSource.query(` + CREATE TABLE "setting" ( + "key" text PRIMARY KEY, + "value" text NOT NULL, + "created" timestamp NOT NULL DEFAULT NOW(), + "updated" timestamp NOT NULL DEFAULT NOW() + ) + `); + }); + + it('resolves the active organization ref and applies a reversible setting update', async () => { + await dataSource.query( + `INSERT INTO "user_data" ("id", "organizationName", "accountType", "status") VALUES + (1, ' DeNaRiO AG ', 'Organization', 'Active'), + (2, 'Other AG', 'Organization', 'Active'), + (3, 'Denario AG', 'Organization', 'Blocked')`, + ); + await dataSource.query( + `INSERT INTO "user" ("id", "userDataId", "status", "ref") VALUES + (1, 1, 'Active', '123-456'), + (2, 2, 'Active', '999-999'), + (3, 3, 'Active', '888-888'), + (4, 1, 'Deleted', '777-777')`, + ); + await dataSource.query(`INSERT INTO "setting" ("key", "value") VALUES ($1, $2)`, [ + 'ref-keys', + JSON.stringify({ cakewallet: '111-222' }), + ]); + + const migration = new AddDenarioPermanentRef(); + const queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + + try { + await migration.up(queryRunner); + const afterUp = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = 'ref-keys'`); + expect(JSON.parse(afterUp.at(0).value)).toEqual({ cakewallet: '111-222', denario: '123-456' }); + + await migration.down(queryRunner); + const afterDown = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = 'ref-keys'`); + expect(JSON.parse(afterDown.at(0).value)).toEqual({ cakewallet: '111-222' }); + } finally { + await queryRunner.release(); + } + }); +}); From 32d8fa65da5358cf3345e7ad2ec5b9141698b4b9 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 15 Jul 2026 11:09:42 +0200 Subject: [PATCH 2/4] feat(asset): add Denario partner wallet and Denario Gold/Silver Polygon assets --- ...1784038000000-AddDenarioWalletAndAssets.js | 75 +++++++++++++++++++ migration/seed/asset.csv | 2 + 2 files changed, 77 insertions(+) create mode 100644 migration/1784038000000-AddDenarioWalletAndAssets.js diff --git a/migration/1784038000000-AddDenarioWalletAndAssets.js b/migration/1784038000000-AddDenarioWalletAndAssets.js new file mode 100644 index 0000000000..bb8e8dbc6d --- /dev/null +++ b/migration/1784038000000-AddDenarioWalletAndAssets.js @@ -0,0 +1,75 @@ +// Onboard Denario: add the "Denario" partner wallet and its two Polygon precious-metal tokens. +// +// Wallet (partner table): +// - "Denario" is added analogously to existing partner wallets (e.g. Cake Wallet). Only name and +// displayName are set; all compliance/behaviour columns take their conservative entity defaults +// (isKycClient=false, autoTradeApproval=false, usesDummyAddresses=false, displayFraudWarning=false, +// amlRules='0', buySpecificIbanEnabled=false). Adjust these once DFX confirms the partner's KYC/AML +// setup — kept conservative on purpose so the partner cannot bypass any check by default. +// +// Assets (Polygon ERC-20, verified on-chain): +// - DGC Denario Gold Coin 0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f decimals 8 +// https://polygonscan.com/token/0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f +// - DSC Denario Silver Coin 0x5d4e735784293a0a8d37761ad93c13a0dd35c7e7 decimals 8 +// https://polygonscan.com/token/0x5d4e735784293a0a8d37761ad93c13a0dd35c7e7 +// Both are added as inert, list-only assets (like OlkyFrozen/EUR): no priceRuleId -> excluded from the +// hourly price job, and every trade/payment flag false -> isActive=false, so no cron/observable picks +// them up. There is no automatic liquidity-management mechanism to buy/sell these tokens; any purchase +// or sale is handled manually. financialType is intentionally left null (no precious-metal type exists +// yet) — set it when trading is enabled. + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddDenarioWalletAndAssets1784038000000 { + name = 'AddDenarioWalletAndAssets1784038000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // Partner wallet — idempotent on name; all other columns take their DB defaults. + await queryRunner.query( + `INSERT INTO "wallet" ("name", "displayName") + SELECT 'Denario', 'Denario' + WHERE NOT EXISTS (SELECT 1 FROM "wallet" WHERE "name" = 'Denario')`, + ); + + // Inert, list-only assets — idempotent on the stable uniqueName (ids are env-specific). + await queryRunner.query( + `INSERT INTO "asset" + ("name", "uniqueName", "type", "blockchain", "category", "dexName", "chainId", "decimals", "description", + "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon") + SELECT 'DGC', 'Polygon/DGC', 'Token', 'Polygon', 'Public', 'DGC', '0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f', 8, 'Denario Gold Coin', + false, false, false, false, false, false, + false, false, true, false, false, false + WHERE NOT EXISTS (SELECT 1 FROM "asset" WHERE "uniqueName" = 'Polygon/DGC')`, + ); + + await queryRunner.query( + `INSERT INTO "asset" + ("name", "uniqueName", "type", "blockchain", "category", "dexName", "chainId", "decimals", "description", + "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon") + SELECT 'DSC', 'Polygon/DSC', 'Token', 'Polygon', 'Public', 'DSC', '0x5d4e735784293a0a8d37761ad93c13a0dd35c7e7', 8, 'Denario Silver Coin', + false, false, false, false, false, false, + false, false, true, false, false, false + WHERE NOT EXISTS (SELECT 1 FROM "asset" WHERE "uniqueName" = 'Polygon/DSC')`, + ); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`DELETE FROM "asset" WHERE "uniqueName" IN ('Polygon/DGC', 'Polygon/DSC')`); + await queryRunner.query(`DELETE FROM "wallet" WHERE "name" = 'Denario'`); + } +}; diff --git a/migration/seed/asset.csv b/migration/seed/asset.csv index 3d0efe4d19..d5ec606606 100644 --- a/migration/seed/asset.csv +++ b/migration/seed/asset.csv @@ -226,3 +226,5 @@ id,name,type,buyable,sellable,chainId,sellCommand,dexName,category,blockchain,un 111,ETH,Coin,TRUE,TRUE,0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,,ETH,Public,Ethereum,Ethereum/ETH,Ether,FALSE,1,2922.079246,FALSE,6,2304.065646,FALSE,FALSE,FALSE,FALSE,Other,18,TRUE,0,0,2480.82045,TRUE 409,FIRO,Coin,TRUE,TRUE,,,FIRO,Public,Firo,Firo/FIRO,,FALSE,,1.5,FALSE,,1.35,FALSE,FALSE,FALSE,FALSE,Other,8,FALSE,0,0,1.4,TRUE 410,EUR,Custody,FALSE,FALSE,,,EUR,Private,OlkyFrozen,OlkyFrozen/EUR,,FALSE,,1.17786809,FALSE,39,0.9287514723,FALSE,FALSE,FALSE,FALSE,EUR,,FALSE,0,0,1,TRUE +411,DGC,Token,FALSE,FALSE,0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f,,DGC,Public,Polygon,Polygon/DGC,Denario Gold Coin,FALSE,,,FALSE,,,FALSE,FALSE,FALSE,FALSE,,8,FALSE,0,0,,TRUE +412,DSC,Token,FALSE,FALSE,0x5d4e735784293a0a8d37761ad93c13a0dd35c7e7,,DSC,Public,Polygon,Polygon/DSC,Denario Silver Coin,FALSE,,,FALSE,,,FALSE,FALSE,FALSE,FALSE,,8,FALSE,0,0,,TRUE From 5f4f986b5f1b8a4e9db303da13fd2eb43fffe0a0 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 15 Jul 2026 14:55:09 +0200 Subject: [PATCH 3/4] fix(payin): register only priced assets and harden Denario migrations - pay-in register strategies recognize only priced assets (new AssetService.getPayInAssets); an unpriced token is no longer mapped into the register flow, where validateInput/pricing throws and would loop the pay-in in CREATED forever - AddDenarioPermanentRef: persist an immutable before-image of ref-keys before overwriting (fail closed in the migration transaction) and revert only the alias this migration set - AddDenarioWalletAndAssets: down() removes only still-inert assets and the wallet only while it has no owner or users - specs: unpriced-asset exclusion from recognition, ref-keys audit/ownership, wallet/asset up->down and ownership preservation --- .../1784037000000-AddDenarioPermanentRef.js | 52 +++++-- ...1784038000000-AddDenarioWalletAndAssets.js | 20 ++- src/shared/models/asset/asset.service.spec.ts | 34 +++++ src/shared/models/asset/asset.service.ts | 12 ++ ...enario-wallet-and-assets.migration.spec.ts | 138 ++++++++++++++++++ ...dd-denario-permanent-ref.migration.spec.ts | 117 +++++++++++---- .../register/impl/base/alchemy.strategy.ts | 4 +- .../register/impl/base/citrea.strategy.ts | 2 +- .../register/impl/binance-pay.strategy.ts | 2 +- .../register/impl/cardano.strategy.ts | 2 +- .../strategies/register/impl/icp.strategy.ts | 2 +- .../register/impl/kucoin-pay.strategy.ts | 2 +- .../register/impl/solana.strategy.ts | 2 +- .../strategies/register/impl/tron.strategy.ts | 2 +- .../strategies/register/impl/zano.strategy.ts | 2 +- 15 files changed, 339 insertions(+), 54 deletions(-) create mode 100644 src/shared/models/asset/tests/add-denario-wallet-and-assets.migration.spec.ts diff --git a/migration/1784037000000-AddDenarioPermanentRef.js b/migration/1784037000000-AddDenarioPermanentRef.js index 326849b69a..55062c6e46 100644 --- a/migration/1784037000000-AddDenarioPermanentRef.js +++ b/migration/1784037000000-AddDenarioPermanentRef.js @@ -4,6 +4,11 @@ */ const REF_SETTING_KEY = 'ref-keys'; +// Immutable prior-state / audit record for this migration's overwrite of `ref-keys` +// (CONTRIBUTING: auditable mutations — no destructive overwrite). Written before the update and only +// on the path that actually changes the setting; it is the recoverable before-image and the ownership +// marker used by down(). +const REF_BACKUP_KEY = 'ref-keys.backup.1784037000000'; const DENARIO_ALIAS = 'denario'; const DENARIO_ORGANIZATION_NAMES = ['denario', 'denario ag']; const REF_CODE_FORMAT = /^\w{1,3}-\w{1,3}$/; @@ -99,6 +104,15 @@ module.exports = class AddDenarioPermanentRef1784037000000 { ); } + // Auditable mutation: persist the immutable before-image FIRST, then change the snapshot. The + // whole migration runs in one transaction, so if this insert fails (e.g. a backup already exists), + // the `ref-keys` update never happens — fail closed, never a destructive overwrite without a record. + const backup = JSON.stringify({ existed: row != null, previous: row ? row.value : null, ownedRef: denarioRef }); + await queryRunner.query( + `INSERT INTO "setting" ("key", "value", "created", "updated") VALUES ($1, $2, NOW(), NOW())`, + [REF_BACKUP_KEY, backup], + ); + Reflect.set(refKeys, DENARIO_ALIAS, denarioRef); const value = JSON.stringify(refKeys); @@ -119,22 +133,36 @@ module.exports = class AddDenarioPermanentRef1784037000000 { * @param {QueryRunner} queryRunner */ async down(queryRunner) { + // Revert only the change this migration owns. The backup record exists exactly when up() changed + // `ref-keys`; without it, up() was a no-op (alias already present) and there is nothing to undo. + const backupRow = ( + await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = $1 FOR UPDATE`, Array.of(REF_BACKUP_KEY)) + ).at(0); + if (!backupRow) return; + + const backup = JSON.parse(backupRow.value); + const row = ( await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = $1 FOR UPDATE`, Array.of(REF_SETTING_KEY)) ).at(0); - if (!row) return; - - const refKeys = parseRefKeys(row); - if (!Object.prototype.hasOwnProperty.call(refKeys, DENARIO_ALIAS)) return; - Reflect.deleteProperty(refKeys, DENARIO_ALIAS); - if (Object.keys(refKeys).length === 0) { - await queryRunner.query(`DELETE FROM "setting" WHERE "key" = $1`, Array.of(REF_SETTING_KEY)); - } else { - await queryRunner.query(`UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, [ - JSON.stringify(refKeys), - REF_SETTING_KEY, - ]); + if (row) { + const refKeys = parseRefKeys(row); + // Only remove the alias while it still carries the value we set — an admin may have re-pointed or + // re-added it after deployment; that later change is not ours to destroy. + if (Reflect.get(refKeys, DENARIO_ALIAS) === backup.ownedRef) { + Reflect.deleteProperty(refKeys, DENARIO_ALIAS); + if (Object.keys(refKeys).length === 0 && !backup.existed) { + await queryRunner.query(`DELETE FROM "setting" WHERE "key" = $1`, Array.of(REF_SETTING_KEY)); + } else { + await queryRunner.query(`UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, [ + JSON.stringify(refKeys), + REF_SETTING_KEY, + ]); + } + } } + + await queryRunner.query(`DELETE FROM "setting" WHERE "key" = $1`, Array.of(REF_BACKUP_KEY)); } }; diff --git a/migration/1784038000000-AddDenarioWalletAndAssets.js b/migration/1784038000000-AddDenarioWalletAndAssets.js index bb8e8dbc6d..9593e533dc 100644 --- a/migration/1784038000000-AddDenarioWalletAndAssets.js +++ b/migration/1784038000000-AddDenarioWalletAndAssets.js @@ -69,7 +69,23 @@ module.exports = class AddDenarioWalletAndAssets1784038000000 { * @param {QueryRunner} queryRunner */ async down(queryRunner) { - await queryRunner.query(`DELETE FROM "asset" WHERE "uniqueName" IN ('Polygon/DGC', 'Polygon/DSC')`); - await queryRunner.query(`DELETE FROM "wallet" WHERE "name" = 'Denario'`); + // Revert only the state this migration owns. up() inserts are NOT EXISTS-guarded (the loc seed can + // create the same rows), so down() must not delete rows it may not have created: remove the assets + // only while they are still inert (no price rule, not tradeable) and the wallet only while it has no + // owner and no attached users. Anything activated or linked afterwards is left in place (roll-forward), + // so a rollback never destroys pre-existing or accumulated data. + await queryRunner.query( + `DELETE FROM "asset" + WHERE "uniqueName" IN ('Polygon/DGC', 'Polygon/DSC') + AND "priceRuleId" IS NULL + AND "buyable" = false + AND "sellable" = false`, + ); + await queryRunner.query( + `DELETE FROM "wallet" + WHERE "name" = 'Denario' + AND "ownerId" IS NULL + AND "id" NOT IN (SELECT "walletId" FROM "user" WHERE "walletId" IS NOT NULL)`, + ); } }; diff --git a/src/shared/models/asset/asset.service.spec.ts b/src/shared/models/asset/asset.service.spec.ts index 2b60d23ed5..be86c63612 100644 --- a/src/shared/models/asset/asset.service.spec.ts +++ b/src/shared/models/asset/asset.service.spec.ts @@ -1,5 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { IsNull, Not } from 'typeorm'; +import { Asset, AssetType } from './asset.entity'; import { AssetRepository } from './asset.repository'; import { AssetService } from './asset.service'; @@ -21,4 +24,35 @@ describe('AssetService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getPayInAssets', () => { + const dgcChainId = '0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f'; + const zchf = { + blockchain: Blockchain.POLYGON, + type: AssetType.TOKEN, + chainId: '0x02567e4b14b25549331fcee2b56c647a8bab16fd', + priceRule: { id: 2 }, + } as unknown as Asset; + + it('queries only priced assets (excludes inert/unpriced tokens at the DB level)', async () => { + const findCached = jest.spyOn(assetRepo, 'findCached').mockResolvedValue([]); + + await service.getPayInAssets([Blockchain.POLYGON]); + + const options = findCached.mock.calls[0][1]; + expect(options.where).toMatchObject({ priceRule: Not(IsNull()) }); + }); + + it('does not recognize an inbound deposit of a token missing from the priced pay-in set, so it cannot loop the pay-in', async () => { + // the DB filter already dropped the unpriced DGC, so only ZCHF comes back + jest.spyOn(assetRepo, 'findCached').mockResolvedValue([zchf]); + + const payInAssets = await service.getPayInAssets([Blockchain.POLYGON]); + + // priced token stays recognizable + expect(service.getByChainIdSync(payInAssets, Blockchain.POLYGON, zchf.chainId)).toBe(zchf); + // inert DGC is not recognized -> mapped asset is undefined -> CryptoInput gets status FAILED (no retry loop) + expect(service.getByChainIdSync(payInAssets, Blockchain.POLYGON, dgcChainId)).toBeUndefined(); + }); + }); }); diff --git a/src/shared/models/asset/asset.service.ts b/src/shared/models/asset/asset.service.ts index 31d222630e..99be7ce8f2 100644 --- a/src/shared/models/asset/asset.service.ts +++ b/src/shared/models/asset/asset.service.ts @@ -49,6 +49,18 @@ export class AssetService { }); } + // Assets that a pay-in register strategy may recognize. Only priced assets qualify: the register + // flow feeds the asset into validateInput() -> pricing, which throws without a price rule and would + // leave the pay-in stuck in CREATED, re-tried and logged by the minute cron forever. Scoped to the + // pay-in path (its own cache key), so balances, monitoring and the asset API stay unaffected. + async getPayInAssets(blockchains: Blockchain[], includePrivate = true): Promise { + const where: FindOptionsWhere = { priceRule: Not(IsNull()) }; + where.blockchain = blockchains.length > 0 ? In(blockchains) : Not(Blockchain.DEFICHAIN); + if (!includePrivate) where.category = Not(AssetCategory.PRIVATE); + + return this.assetRepo.findCached(`pay-in-${JSON.stringify(where)}`, { where }); + } + async getPaymentAssets(): Promise { return this.assetRepo.findCachedBy('payment', { paymentEnabled: true }); } diff --git a/src/shared/models/asset/tests/add-denario-wallet-and-assets.migration.spec.ts b/src/shared/models/asset/tests/add-denario-wallet-and-assets.migration.spec.ts new file mode 100644 index 0000000000..6c5be772de --- /dev/null +++ b/src/shared/models/asset/tests/add-denario-wallet-and-assets.migration.spec.ts @@ -0,0 +1,138 @@ +import { DataType, newDb } from 'pg-mem'; +import { DataSource, QueryRunner } from 'typeorm'; + +type Migration = { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +let AddDenarioWalletAndAssets: new () => Migration; + +async function countAsset(queryRunner: QueryRunner, uniqueName: string): Promise { + const rows = await queryRunner.query(`SELECT COUNT(*)::int AS c FROM "asset" WHERE "uniqueName" = '${uniqueName}'`); + return rows.at(0).c; +} + +async function countWallet(queryRunner: QueryRunner): Promise { + const rows = await queryRunner.query(`SELECT COUNT(*)::int AS c FROM "wallet" WHERE "name" = 'Denario'`); + return rows.at(0).c; +} + +describe('AddDenarioWalletAndAssets migration (postgres semantics)', () => { + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddDenarioWalletAndAssets = require('../../../../../migration/1784038000000-AddDenarioWalletAndAssets'); + }); + + beforeEach(async () => { + const db = newDb(); + db.public.registerFunction({ name: 'version', returns: DataType.text, implementation: () => 'PostgreSQL 15.0' }); + db.public.registerFunction({ name: 'current_database', returns: DataType.text, implementation: () => 'test' }); + + dataSource = (await db.adapters.createTypeormDataSource({ type: 'postgres', entities: [] })) as DataSource; + await dataSource.initialize(); + + await dataSource.query(` + CREATE TABLE "asset" ( + "id" serial PRIMARY KEY, + "name" text NOT NULL, + "uniqueName" text NOT NULL, + "type" text NOT NULL, + "blockchain" text NOT NULL, + "category" text NOT NULL, + "dexName" text, + "chainId" text, + "decimals" integer, + "description" text, + "buyable" boolean NOT NULL, + "sellable" boolean NOT NULL, + "cardBuyable" boolean NOT NULL, + "cardSellable" boolean NOT NULL, + "instantBuyable" boolean NOT NULL, + "instantSellable" boolean NOT NULL, + "paymentEnabled" boolean NOT NULL, + "refEnabled" boolean NOT NULL, + "refundEnabled" boolean NOT NULL, + "ikna" boolean NOT NULL, + "personalIbanEnabled" boolean NOT NULL, + "comingSoon" boolean NOT NULL, + "priceRuleId" integer + ) + `); + await dataSource.query(` + CREATE TABLE "wallet" ( + "id" serial PRIMARY KEY, + "name" text, + "displayName" text, + "ownerId" integer + ) + `); + await dataSource.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY, "walletId" integer)`); + + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + }); + + afterEach(async () => { + await queryRunner.release(); + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + it('inserts the Denario wallet and the two inert Polygon assets', async () => { + await new AddDenarioWalletAndAssets().up(queryRunner); + + expect(await countWallet(queryRunner)).toBe(1); + const dgc = (await queryRunner.query(`SELECT * FROM "asset" WHERE "uniqueName" = 'Polygon/DGC'`)).at(0); + expect(dgc).toMatchObject({ + name: 'DGC', + type: 'Token', + blockchain: 'Polygon', + chainId: '0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f', + decimals: 8, + buyable: false, + sellable: false, + priceRuleId: null, + }); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(1); + }); + + it('is idempotent — a second up() does not duplicate rows', async () => { + await new AddDenarioWalletAndAssets().up(queryRunner); + await new AddDenarioWalletAndAssets().up(queryRunner); + + expect(await countWallet(queryRunner)).toBe(1); + expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(1); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(1); + }); + + it('down() removes the inert assets and the unused wallet it created', async () => { + await new AddDenarioWalletAndAssets().up(queryRunner); + + await new AddDenarioWalletAndAssets().down(queryRunner); + + expect(await countWallet(queryRunner)).toBe(0); + expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(0); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(0); + }); + + it('down() preserves assets that were activated and a wallet that has users attached', async () => { + await new AddDenarioWalletAndAssets().up(queryRunner); + + // DGC gets a price rule, DSC is made buyable (i.e. later activated for trading) + await queryRunner.query(`UPDATE "asset" SET "priceRuleId" = 1 WHERE "uniqueName" = 'Polygon/DGC'`); + await queryRunner.query(`UPDATE "asset" SET "buyable" = true WHERE "uniqueName" = 'Polygon/DSC'`); + // a user is linked to the Denario wallet + const walletId = (await queryRunner.query(`SELECT "id" FROM "wallet" WHERE "name" = 'Denario'`)).at(0).id; + await queryRunner.query(`INSERT INTO "user" ("id", "walletId") VALUES (1, ${walletId})`); + + await new AddDenarioWalletAndAssets().down(queryRunner); + + // nothing that gained real state is destroyed (roll-forward) + expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(1); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(1); + expect(await countWallet(queryRunner)).toBe(1); + }); +}); diff --git a/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts b/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts index 71f514c20d..dcf3ccd892 100644 --- a/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts +++ b/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts @@ -6,29 +6,36 @@ type Migration = { down(queryRunner: QueryRunner): Promise; }; +const BACKUP_KEY = 'ref-keys.backup.1784037000000'; + type MigrationHarness = { queryRunner: QueryRunner; query: jest.Mock; - getSetting(): string | undefined; + getSetting(key?: string): string | undefined; }; -let AddDenarioPermanentRef: new () => Migration; - function createHarness(refs: unknown[] = ['123-456'], initialSetting?: string): MigrationHarness { - let setting = initialSetting; + const settings = new Map(); + if (initialSetting !== undefined) settings.set('ref-keys', initialSetting); + const query = jest.fn(async (sql: string, parameters: unknown[] = []) => { if (sql.includes('FROM "user" u')) return refs.map((ref) => ({ ref })); - if (sql.startsWith('SELECT "value" FROM "setting"')) return setting === undefined ? [] : [{ value: setting }]; + if (sql.startsWith('SELECT "value" FROM "setting"')) { + const key = parameters[0] as string; + return settings.has(key) ? [{ value: settings.get(key) }] : []; + } if (sql.startsWith('UPDATE "setting"')) { - setting = parameters[0] as string; + settings.set(parameters[1] as string, parameters[0] as string); return []; } if (sql.startsWith('INSERT INTO "setting"')) { - setting = parameters[1] as string; + const key = parameters[0] as string; + if (settings.has(key)) throw new Error(`duplicate key value violates unique constraint "${key}"`); + settings.set(key, parameters[1] as string); return []; } if (sql.startsWith('DELETE FROM "setting"')) { - setting = undefined; + settings.delete(parameters[0] as string); return []; } @@ -38,10 +45,12 @@ function createHarness(refs: unknown[] = ['123-456'], initialSetting?: string): return { queryRunner: { query } as unknown as QueryRunner, query, - getSetting: () => setting, + getSetting: (key = 'ref-keys') => settings.get(key), }; } +let AddDenarioPermanentRef: new () => Migration; + describe('AddDenarioPermanentRef migration', () => { beforeAll(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -64,33 +73,52 @@ describe('AddDenarioPermanentRef migration', () => { ]); }); - it('creates ref-keys when the setting does not exist', async () => { + it('writes an immutable before-image backup before overwriting ref-keys', async () => { + const previous = JSON.stringify({ cakewallet: '111-222' }); + const harness = createHarness(['123-456'], previous); + + await new AddDenarioPermanentRef().up(harness.queryRunner); + + expect(JSON.parse(harness.getSetting(BACKUP_KEY)!)).toEqual({ existed: true, previous, ownedRef: '123-456' }); + // backup is inserted before the ref-keys update + const insertBackupCall = harness.query.mock.calls.findIndex( + ([sql, params]) => /^INSERT INTO "setting"/.test(sql) && params?.[0] === BACKUP_KEY, + ); + const updateRefKeysCall = harness.query.mock.calls.findIndex(([sql]) => /^UPDATE "setting"/.test(sql)); + expect(insertBackupCall).toBeGreaterThanOrEqual(0); + expect(insertBackupCall).toBeLessThan(updateRefKeysCall); + }); + + it('creates ref-keys when the setting does not exist and records existed=false', async () => { const harness = createHarness(); await new AddDenarioPermanentRef().up(harness.queryRunner); expect(JSON.parse(harness.getSetting()!)).toEqual({ denario: '123-456' }); - expect(harness.query).toHaveBeenCalledWith(expect.stringMatching(/^INSERT INTO "setting"/), [ - 'ref-keys', - JSON.stringify({ denario: '123-456' }), - ]); + expect(JSON.parse(harness.getSetting(BACKUP_KEY)!)).toEqual({ + existed: false, + previous: null, + ownedRef: '123-456', + }); }); - it('is idempotent when the alias already targets the same account', async () => { + it('is idempotent and writes no backup when the alias already targets the same account', async () => { const initialSetting = JSON.stringify({ denario: '123-456' }); const harness = createHarness(['123-456'], initialSetting); await new AddDenarioPermanentRef().up(harness.queryRunner); expect(harness.getSetting()).toBe(initialSetting); + expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); expect(harness.query).toHaveBeenCalledTimes(2); }); - it('refuses to overwrite a conflicting Denario alias', async () => { + it('refuses to overwrite a conflicting Denario alias and writes no backup', async () => { const harness = createHarness(['123-456'], JSON.stringify({ denario: '999-999' })); await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow('conflicting'); expect(JSON.parse(harness.getSetting()!)).toEqual({ denario: '999-999' }); + expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); }); it.each([ @@ -102,6 +130,7 @@ describe('AddDenarioPermanentRef migration', () => { await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow(error); expect(harness.getSetting()).toBeUndefined(); + expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); }); it.each(['not-json', '[]', 'null'])('rejects corrupt ref-keys configuration: %s', async (value) => { @@ -109,35 +138,55 @@ describe('AddDenarioPermanentRef migration', () => { await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow("Setting 'ref-keys'"); expect(harness.getSetting()).toBe(value); + expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); }); - it('removes only the Denario alias on rollback', async () => { - const harness = createHarness([], JSON.stringify({ cakewallet: '111-222', denario: '123-456' })); + it('removes only the Denario alias on rollback and clears the backup', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); + await new AddDenarioPermanentRef().up(harness.queryRunner); await new AddDenarioPermanentRef().down(harness.queryRunner); expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222' }); + expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); }); - it('removes an otherwise empty ref-keys setting on rollback', async () => { - const harness = createHarness([], JSON.stringify({ denario: '123-456' })); + it('removes an otherwise empty ref-keys setting on rollback when it did not exist before', async () => { + const harness = createHarness(['123-456']); + await new AddDenarioPermanentRef().up(harness.queryRunner); await new AddDenarioPermanentRef().down(harness.queryRunner); expect(harness.getSetting()).toBeUndefined(); + expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); + }); + + it('is a no-op on rollback when up() never owned a change (no backup)', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); + + await new AddDenarioPermanentRef().down(harness.queryRunner); + + expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222' }); }); - it.each([undefined, JSON.stringify({ cakewallet: '111-222' })])( - 'leaves unrelated configuration untouched on rollback: %s', - async (value) => { - const harness = createHarness([], value); + it('does not destroy a Denario alias re-pointed after deployment', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); + await new AddDenarioPermanentRef().up(harness.queryRunner); - await new AddDenarioPermanentRef().down(harness.queryRunner); + // an admin re-points the alias after deployment + harness.query.mock.calls.length = 0; + const current = JSON.parse(harness.getSetting()!); + current.denario = '555-666'; + (harness.queryRunner as unknown as { query: jest.Mock }).query( + `UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, + [JSON.stringify(current), 'ref-keys'], + ); - expect(harness.getSetting()).toBe(value); - expect(harness.query).toHaveBeenCalledTimes(1); - }, - ); + await new AddDenarioPermanentRef().down(harness.queryRunner); + + expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222', denario: '555-666' }); + expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); + }); }); describe('AddDenarioPermanentRef migration (postgres semantics)', () => { @@ -195,7 +244,7 @@ describe('AddDenarioPermanentRef migration (postgres semantics)', () => { `); }); - it('resolves the active organization ref and applies a reversible setting update', async () => { + it('resolves the active organization ref and applies a reversible, auditable setting update', async () => { await dataSource.query( `INSERT INTO "user_data" ("id", "organizationName", "accountType", "status") VALUES (1, ' DeNaRiO AG ', 'Organization', 'Active'), @@ -222,10 +271,18 @@ describe('AddDenarioPermanentRef migration (postgres semantics)', () => { await migration.up(queryRunner); const afterUp = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = 'ref-keys'`); expect(JSON.parse(afterUp.at(0).value)).toEqual({ cakewallet: '111-222', denario: '123-456' }); + const backup = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = '${BACKUP_KEY}'`); + expect(JSON.parse(backup.at(0).value)).toEqual({ + existed: true, + previous: JSON.stringify({ cakewallet: '111-222' }), + ownedRef: '123-456', + }); await migration.down(queryRunner); const afterDown = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = 'ref-keys'`); expect(JSON.parse(afterDown.at(0).value)).toEqual({ cakewallet: '111-222' }); + const backupAfterDown = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = '${BACKUP_KEY}'`); + expect(backupAfterDown).toHaveLength(0); } finally { await queryRunner.release(); } diff --git a/src/subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts index 285bab58fd..ea561bbcf7 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts @@ -58,7 +58,7 @@ export abstract class AlchemyStrategy extends EvmStrategy implements OnModuleIni const fromAddresses = this.getOwnAddresses(); const toAddresses = await this.getPayInAddresses(); - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); const relevantTransactions = this.filterWebhookTransactionsByRelevantAddresses(fromAddresses, toAddresses, dto); const transactions = AlchemyTransactionMapper.mapWebhookActivities(relevantTransactions); @@ -97,7 +97,7 @@ export abstract class AlchemyStrategy extends EvmStrategy implements OnModuleIni const fromAddresses = this.getOwnAddresses(); const toAddresses = await this.getPayInAddresses(); - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); const relevantAssetTransfers = this.filterAssetTransfersByRelevantAddresses( fromAddresses, diff --git a/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts index 39867352ad..fb253de7d7 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts @@ -98,7 +98,7 @@ export abstract class CitreaBaseStrategy extends RegisterStrategy { toBlock, ); - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); const coinEntries = this.mapCoinTransactionsToEntries(coinTransactions, depositAddress, supportedAssets); const tokenEntries = this.mapTokenTransactionsToEntries(tokenTransactions, depositAddress, supportedAssets); diff --git a/src/subdomains/supporting/payin/strategies/register/impl/binance-pay.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/binance-pay.strategy.ts index 47418acebb..76a82f1874 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/binance-pay.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/binance-pay.strategy.ts @@ -44,7 +44,7 @@ export class BinancePayStrategy extends RegisterStrategy { } private async processWebhookTransactions(payWebhook: C2BWebhookResult): Promise { - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); const payInEntry = this.mapBinanceTransaction(payWebhook, supportedAssets); diff --git a/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts index cec4af850a..a5f00c7f03 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts @@ -111,7 +111,7 @@ export class CardanoStrategy extends RegisterStrategy { const transactions = await this.payInCardanoService.getHistoryForAddress(depositAddress.address, 50); const relevantTransactions = this.filterByRelevantTransactions(transactions, depositAddress, fromBlock, toBlock); - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); return this.mapToPayInEntries(depositAddress, relevantTransactions, supportedAssets); } diff --git a/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts index 46a4777fa9..7843ab5541 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts @@ -68,7 +68,7 @@ export class InternetComputerStrategy extends RegisterStrategy { ): Promise { const log = this.createNewLogObject(); const scanState: Record = persistProgress ? await this.getScanState() : {}; - const assets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const assets = await this.assetService.getPayInAssets([this.blockchain]); const newEntries: PayInEntry[] = []; for (const asset of assets) { diff --git a/src/subdomains/supporting/payin/strategies/register/impl/kucoin-pay.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/kucoin-pay.strategy.ts index 0b35157728..68ee584808 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/kucoin-pay.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/kucoin-pay.strategy.ts @@ -44,7 +44,7 @@ export class KucoinPayStrategy extends RegisterStrategy { } private async processWebhookTransactions(payWebhook: C2BWebhookResult): Promise { - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); const payInEntry = this.mapKucoinTransaction(payWebhook, supportedAssets); diff --git a/src/subdomains/supporting/payin/strategies/register/impl/solana.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/solana.strategy.ts index f1061eb4d6..6cd06c8026 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/solana.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/solana.strategy.ts @@ -66,7 +66,7 @@ export class SolanaStrategy extends RegisterStrategy implements OnModuleInit { if (Util.includesIgnoreCase(dto.counterAddresses, ownWalletAddress)) return; if (!Util.includesIgnoreCase(toAddresses, dto.address)) return; - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); const payInEntry = this.mapSolanaTransaction(dto, supportedAssets); diff --git a/src/subdomains/supporting/payin/strategies/register/impl/tron.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/tron.strategy.ts index 17c035b84f..0e9bbebe77 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/tron.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/tron.strategy.ts @@ -66,7 +66,7 @@ export class TronStrategy extends RegisterStrategy implements OnModuleInit { if (Util.includesIgnoreCase(dto.counterAddresses, ownWalletAddress)) return; if (!Util.includesIgnoreCase(toAddresses, dto.address)) return; - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); const payInEntry = this.mapTronTransaction(dto, supportedAssets); diff --git a/src/subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts index e04d26326a..8834b7d375 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts @@ -69,7 +69,7 @@ export class ZanoStrategy extends PollingStrategy { const transferInResults = await this.payInZanoService.getTransactionHistory(lastCheckedBlockHeight); const relevantTransferInResults = this.filterByRelevantPayments(transferInResults); - const supportedAssets = await this.assetService.getAllBlockchainAssets([this.blockchain]); + const supportedAssets = await this.assetService.getPayInAssets([this.blockchain]); return this.mapToPayInEntries(relevantTransferInResults, supportedAssets); } From 655c3722be26dfcbe6f0267e2fd5da34dd92686b Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 15 Jul 2026 17:07:07 +0200 Subject: [PATCH 4/4] fix: harden Denario migrations and ONDO pay-ins --- ...1784036500000-CreateMigrationAuditStore.js | 118 +++++ .../1784037000000-AddDenarioPermanentRef.js | 190 ++++++-- ...1784038000000-AddDenarioWalletAndAssets.js | 414 ++++++++++++++++-- migration/1784039000000-LinkOndoPriceRule.js | 240 ++++++++++ migration/seed/asset.csv | 2 +- src/shared/models/asset/asset.service.spec.ts | 5 +- ...enario-wallet-and-assets.migration.spec.ts | 224 +++++++++- .../models/asset/tests/asset-seed.spec.ts | 107 +++++ .../link-ondo-price-rule.migration.spec.ts | 244 +++++++++++ ...dd-denario-permanent-ref.migration.spec.ts | 254 +++++++++-- .../user/models/wallet/wallet.entity.ts | 1 + .../__tests__/crypto-input.entity.spec.ts | 23 +- .../payin/services/payin.service.ts | 9 +- .../base/__tests__/alchemy.strategy.spec.ts | 216 +++++++++ .../register/impl/base/alchemy.strategy.ts | 2 +- 15 files changed, 1929 insertions(+), 120 deletions(-) create mode 100644 migration/1784036500000-CreateMigrationAuditStore.js create mode 100644 migration/1784039000000-LinkOndoPriceRule.js create mode 100644 src/shared/models/asset/tests/asset-seed.spec.ts create mode 100644 src/shared/models/asset/tests/link-ondo-price-rule.migration.spec.ts create mode 100644 src/subdomains/supporting/payin/strategies/register/impl/base/__tests__/alchemy.strategy.spec.ts diff --git a/migration/1784036500000-CreateMigrationAuditStore.js b/migration/1784036500000-CreateMigrationAuditStore.js new file mode 100644 index 0000000000..f824f840b6 --- /dev/null +++ b/migration/1784036500000-CreateMigrationAuditStore.js @@ -0,0 +1,118 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Creates the internal append-only authority used by data migrations that must later prove ownership + * before reverting a row. It deliberately has no TypeORM entity or API surface: operational logs are + * observable and mutable, whereas migration ownership must not be writable through runtime endpoints. + * + * The tables survive down(). Removing them would destroy the exact evidence required by older migration + * rollbacks and would recreate the audit-loss defect this store exists to prevent. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class CreateMigrationAuditStore1784036500000 { + name = 'CreateMigrationAuditStore1784036500000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "migration_audit_lock" ( + "migration" character varying(256) NOT NULL, + "created" timestamp NOT NULL DEFAULT NOW(), + CONSTRAINT "PK_77ecea30e5135dde115ab5aa8f9" PRIMARY KEY ("migration") + ) + `); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "migration_audit_event" ( + "id" BIGSERIAL NOT NULL, + "created" timestamp NOT NULL DEFAULT NOW(), + "migration" character varying(256) NOT NULL, + "eventType" character varying(16) NOT NULL, + "applyEventId" bigint, + "payload" jsonb NOT NULL, + CONSTRAINT "PK_38dbb698fe7f285a122e714a2e2" PRIMARY KEY ("id"), + CONSTRAINT "UQ_b2ddb93ddceb82a4fe952d7270f" UNIQUE ("id", "migration"), + CONSTRAINT "UQ_330ac82627cb0622555863f8240" UNIQUE ("applyEventId"), + CONSTRAINT "CHK_91534bc9ba42dc6285cca9321d" CHECK ( + ("eventType" = 'Apply' AND "applyEventId" IS NULL) + OR ("eventType" = 'Rollback' AND "applyEventId" IS NOT NULL) + ), + CONSTRAINT "CHK_0aeb94fc20a8b5f808f46561c4" CHECK (jsonb_typeof("payload") = 'object'), + CONSTRAINT "FK_4f1f02a761b4e4fe442b7b653f6" FOREIGN KEY ("applyEventId", "migration") + REFERENCES "migration_audit_event" ("id", "migration") ON DELETE RESTRICT ON UPDATE RESTRICT + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_b2ddb93ddceb82a4fe952d7270" + ON "migration_audit_event" ("migration", "id") + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION "rejectMigrationAuditMutation"() + RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'migration audit records are append-only'; + END; + $$ LANGUAGE plpgsql + `); + await queryRunner.query(` + CREATE OR REPLACE FUNCTION "validateMigrationAuditEventInsert"() + RETURNS trigger AS $$ + BEGIN + IF NEW."eventType" = 'Rollback' AND NOT EXISTS ( + SELECT 1 FROM "migration_audit_event" + WHERE "id" = NEW."applyEventId" + AND "migration" = NEW."migration" + AND "eventType" = 'Apply' + ) THEN + RAISE EXCEPTION 'rollback must reference an apply event from the same migration'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + `); + await queryRunner.query( + `DROP TRIGGER IF EXISTS "TR_migration_audit_event_validate_insert" ON "migration_audit_event"`, + ); + await queryRunner.query(` + CREATE TRIGGER "TR_migration_audit_event_validate_insert" + BEFORE INSERT ON "migration_audit_event" + FOR EACH ROW EXECUTE FUNCTION "validateMigrationAuditEventInsert"() + `); + await queryRunner.query(`DROP TRIGGER IF EXISTS "TR_migration_audit_event_immutable" ON "migration_audit_event"`); + await queryRunner.query(` + CREATE TRIGGER "TR_migration_audit_event_immutable" + BEFORE UPDATE OR DELETE ON "migration_audit_event" + FOR EACH ROW EXECUTE FUNCTION "rejectMigrationAuditMutation"() + `); + await queryRunner.query(`DROP TRIGGER IF EXISTS "TR_migration_audit_event_no_truncate" ON "migration_audit_event"`); + await queryRunner.query(` + CREATE TRIGGER "TR_migration_audit_event_no_truncate" + BEFORE TRUNCATE ON "migration_audit_event" + FOR EACH STATEMENT EXECUTE FUNCTION "rejectMigrationAuditMutation"() + `); + await queryRunner.query(`DROP TRIGGER IF EXISTS "TR_migration_audit_lock_immutable" ON "migration_audit_lock"`); + await queryRunner.query(` + CREATE TRIGGER "TR_migration_audit_lock_immutable" + BEFORE UPDATE OR DELETE ON "migration_audit_lock" + FOR EACH ROW EXECUTE FUNCTION "rejectMigrationAuditMutation"() + `); + await queryRunner.query(`DROP TRIGGER IF EXISTS "TR_migration_audit_lock_no_truncate" ON "migration_audit_lock"`); + await queryRunner.query(` + CREATE TRIGGER "TR_migration_audit_lock_no_truncate" + BEFORE TRUNCATE ON "migration_audit_lock" + FOR EACH STATEMENT EXECUTE FUNCTION "rejectMigrationAuditMutation"() + `); + } + + /** + * Intentionally retained: audit authority and lock rows must outlive the business migrations they record. + */ + async down() {} +}; diff --git a/migration/1784037000000-AddDenarioPermanentRef.js b/migration/1784037000000-AddDenarioPermanentRef.js index 55062c6e46..e5028e123b 100644 --- a/migration/1784037000000-AddDenarioPermanentRef.js +++ b/migration/1784037000000-AddDenarioPermanentRef.js @@ -4,14 +4,12 @@ */ const REF_SETTING_KEY = 'ref-keys'; -// Immutable prior-state / audit record for this migration's overwrite of `ref-keys` -// (CONTRIBUTING: auditable mutations — no destructive overwrite). Written before the update and only -// on the path that actually changes the setting; it is the recoverable before-image and the ownership -// marker used by down(). -const REF_BACKUP_KEY = 'ref-keys.backup.1784037000000'; const DENARIO_ALIAS = 'denario'; const DENARIO_ORGANIZATION_NAMES = ['denario', 'denario ag']; const REF_CODE_FORMAT = /^\w{1,3}-\w{1,3}$/; +const AUDIT_MIGRATION = 'AddDenarioPermanentRef1784037000000'; +const APPLY_ACTION = 'applyDenarioReferralAlias'; +const ROLLBACK_ACTION = 'rollbackDenarioReferralAlias'; /** * Parse the ref-keys setting without silently replacing corrupt configuration. @@ -36,6 +34,103 @@ function parseRefKeys(row) { return refKeys; } +/** + * Return the single apply event that has not yet been matched by a rollback event. + * Audit rows are append-only so a migration can be reverted and applied again without + * destroying either transition. + * + * @param {QueryRunner} queryRunner + * @returns {Promise<({ id: number, existed: boolean, ownedRef: string } & Record) | undefined>} + */ +async function getActiveApplyAudit(queryRunner) { + await queryRunner.query( + `INSERT INTO "migration_audit_lock" ("migration") VALUES ($1) ON CONFLICT ("migration") DO NOTHING`, + Array.of(AUDIT_MIGRATION), + ); + await queryRunner.query(`SELECT "migration" FROM "migration_audit_lock" WHERE "migration" = $1 FOR UPDATE`, [ + AUDIT_MIGRATION, + ]); + + const rows = await queryRunner.query( + `SELECT "id", "eventType", "applyEventId", "payload" FROM "migration_audit_event" + WHERE "migration" = $1 + ORDER BY "id" FOR UPDATE`, + Array.of(AUDIT_MIGRATION), + ); + + const applies = []; + const rolledBackApplyIds = new Set(); + + for (const row of rows) { + const logId = Number(row.id); + if (!Number.isSafeInteger(logId) || logId <= 0) { + throw new Error(`Invalid audit event id '${row.id}' for ${AUDIT_MIGRATION}`); + } + + let event; + try { + event = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload; + } catch { + throw new Error(`Corrupt audit event ${row.id} for ${AUDIT_MIGRATION}`); + } + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw new Error(`Invalid audit payload ${row.id} for ${AUDIT_MIGRATION}`); + } + + if (row.eventType === 'Apply') { + if ( + event.action !== APPLY_ACTION || + event.settingKey !== REF_SETTING_KEY || + typeof event.existed !== 'boolean' || + (event.previous !== null && typeof event.previous !== 'string') || + typeof event.next !== 'string' || + typeof event.ownedRef !== 'string' || + !REF_CODE_FORMAT.test(event.ownedRef) + ) { + throw new Error(`Invalid apply audit event ${logId} for ${AUDIT_MIGRATION}`); + } + applies.push({ ...event, id: logId }); + } else if (row.eventType === 'Rollback') { + const applyEventId = Number(row.applyEventId); + if ( + event.action !== ROLLBACK_ACTION || + !Number.isSafeInteger(applyEventId) || + applyEventId <= 0 || + Number(event.applyLogId) !== applyEventId + ) { + throw new Error(`Invalid rollback audit event ${logId} for ${AUDIT_MIGRATION}`); + } + rolledBackApplyIds.add(applyEventId); + } else { + throw new Error(`Invalid audit event type '${row.eventType}' for ${AUDIT_MIGRATION}`); + } + } + + const activeApplies = applies.filter((event) => !rolledBackApplyIds.has(event.id)); + if (activeApplies.length > 1) { + throw new Error(`Ambiguous audit state for ${AUDIT_MIGRATION}: ${activeApplies.length} active apply events`); + } + + return activeApplies.at(0); +} + +/** + * @param {QueryRunner} queryRunner + * @param {Record} event + * @returns {Promise} + */ +async function writeAuditEvent(queryRunner, event) { + const isApply = event.action === APPLY_ACTION; + const isRollback = event.action === ROLLBACK_ACTION; + if (!isApply && !isRollback) throw new Error(`Invalid audit action for ${AUDIT_MIGRATION}`); + + await queryRunner.query( + `INSERT INTO "migration_audit_event" ("migration", "eventType", "applyEventId", "payload") + VALUES ($1, $2, $3, $4::jsonb)`, + [AUDIT_MIGRATION, isApply ? 'Apply' : 'Rollback', isRollback ? event.applyLogId : null, JSON.stringify(event)], + ); +} + /** * Resolve the environment-specific referral code from the Denario organization account. * Production and development assign referral codes independently, so persisting one numeric @@ -91,12 +186,23 @@ module.exports = class AddDenarioPermanentRef1784037000000 { * @param {QueryRunner} queryRunner */ async up(queryRunner) { - const denarioRef = await resolveDenarioRef(queryRunner); + // Serialize apply/reapply/rollback before taking the mutable setting lock. This keeps lock ordering + // identical to down() and prevents a recovery reapply from creating a second active ownership event. + const activeApply = await getActiveApplyAudit(queryRunner); + const denarioRef = activeApply?.ownedRef ?? (await resolveDenarioRef(queryRunner)); const row = ( await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = $1 FOR UPDATE`, Array.of(REF_SETTING_KEY)) ).at(0); const refKeys = parseRefKeys(row); + if (activeApply) { + if (Reflect.get(refKeys, DENARIO_ALIAS) === activeApply.ownedRef) return; + throw new Error( + `Active ${AUDIT_MIGRATION} ownership event no longer matches the '${DENARIO_ALIAS}' alias; ` + + 'run the ownership-safe rollback before reapplying', + ); + } + if (Object.prototype.hasOwnProperty.call(refKeys, DENARIO_ALIAS)) { if (Reflect.get(refKeys, DENARIO_ALIAS) === denarioRef) return; throw new Error( @@ -104,18 +210,20 @@ module.exports = class AddDenarioPermanentRef1784037000000 { ); } - // Auditable mutation: persist the immutable before-image FIRST, then change the snapshot. The - // whole migration runs in one transaction, so if this insert fails (e.g. a backup already exists), - // the `ref-keys` update never happens — fail closed, never a destructive overwrite without a record. - const backup = JSON.stringify({ existed: row != null, previous: row ? row.value : null, ownedRef: denarioRef }); - await queryRunner.query( - `INSERT INTO "setting" ("key", "value", "created", "updated") VALUES ($1, $2, NOW(), NOW())`, - [REF_BACKUP_KEY, backup], - ); - Reflect.set(refKeys, DENARIO_ALIAS, denarioRef); const value = JSON.stringify(refKeys); + // The append-only before -> after event is written before the mutable snapshot. Migration execution + // is transactional, so either both writes commit or neither does. The event deliberately survives down(). + await writeAuditEvent(queryRunner, { + action: APPLY_ACTION, + settingKey: REF_SETTING_KEY, + existed: row != null, + previous: row ? row.value : null, + next: value, + ownedRef: denarioRef, + }); + if (row) { await queryRunner.query(`UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, [ value, @@ -133,36 +241,52 @@ module.exports = class AddDenarioPermanentRef1784037000000 { * @param {QueryRunner} queryRunner */ async down(queryRunner) { - // Revert only the change this migration owns. The backup record exists exactly when up() changed - // `ref-keys`; without it, up() was a no-op (alias already present) and there is nothing to undo. - const backupRow = ( - await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = $1 FOR UPDATE`, Array.of(REF_BACKUP_KEY)) - ).at(0); - if (!backupRow) return; - - const backup = JSON.parse(backupRow.value); + const applyAudit = await getActiveApplyAudit(queryRunner); + if (!applyAudit) return; const row = ( await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = $1 FOR UPDATE`, Array.of(REF_SETTING_KEY)) ).at(0); + let next = row?.value ?? null; + let outcome = 'skippedSettingMissing'; + if (row) { const refKeys = parseRefKeys(row); // Only remove the alias while it still carries the value we set — an admin may have re-pointed or // re-added it after deployment; that later change is not ours to destroy. - if (Reflect.get(refKeys, DENARIO_ALIAS) === backup.ownedRef) { + if (Reflect.get(refKeys, DENARIO_ALIAS) === applyAudit.ownedRef) { Reflect.deleteProperty(refKeys, DENARIO_ALIAS); - if (Object.keys(refKeys).length === 0 && !backup.existed) { - await queryRunner.query(`DELETE FROM "setting" WHERE "key" = $1`, Array.of(REF_SETTING_KEY)); - } else { - await queryRunner.query(`UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, [ - JSON.stringify(refKeys), - REF_SETTING_KEY, - ]); - } + next = Object.keys(refKeys).length === 0 && !applyAudit.existed ? null : JSON.stringify(refKeys); + outcome = 'reverted'; + } else { + outcome = Object.prototype.hasOwnProperty.call(refKeys, DENARIO_ALIAS) + ? 'skippedAliasRepointed' + : 'skippedAliasMissing'; } } - await queryRunner.query(`DELETE FROM "setting" WHERE "key" = $1`, Array.of(REF_BACKUP_KEY)); + // Record the rollback decision and its exact before -> after state before touching the snapshot. + // Even a skipped rollback remains reconstructible and the original apply event is never deleted. + await writeAuditEvent(queryRunner, { + action: ROLLBACK_ACTION, + applyLogId: applyAudit.id, + settingKey: REF_SETTING_KEY, + previous: row?.value ?? null, + next, + outcome, + ownedRef: applyAudit.ownedRef, + }); + + if (outcome !== 'reverted') return; + + if (next == null) { + await queryRunner.query(`DELETE FROM "setting" WHERE "key" = $1`, Array.of(REF_SETTING_KEY)); + } else { + await queryRunner.query(`UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, [ + next, + REF_SETTING_KEY, + ]); + } } }; diff --git a/migration/1784038000000-AddDenarioWalletAndAssets.js b/migration/1784038000000-AddDenarioWalletAndAssets.js index 9593e533dc..7a482360b2 100644 --- a/migration/1784038000000-AddDenarioWalletAndAssets.js +++ b/migration/1784038000000-AddDenarioWalletAndAssets.js @@ -23,6 +23,252 @@ * @typedef {import('typeorm').QueryRunner} QueryRunner */ +const { isDeepStrictEqual } = require('node:util'); + +const AUDIT_MIGRATION = 'AddDenarioWalletAndAssets1784038000000'; +const APPLY_ACTION = 'applyDenarioWalletAndAssets'; +const ROLLBACK_ACTION = 'rollbackDenarioWalletAndAssets'; +const DENARIO_WALLET_NAME_INDEX = 'IDX_8f34480ca127806f8393bd56fb'; +const EXPECTED_WALLET = { + address: null, + name: 'Denario', + displayName: 'Denario', + isKycClient: false, + displayFraudWarning: false, + usesDummyAddresses: false, + customKyc: null, + identMethod: null, + apiUrl: null, + apiKey: null, + amlRules: '0', + exceptAmlRules: null, + webhookConfig: null, + mailConfig: null, + autoTradeApproval: false, + buySpecificIbanEnabled: false, + ownerId: null, +}; +const EXPECTED_ASSETS = [ + { + name: 'DGC', + uniqueName: 'Polygon/DGC', + type: 'Token', + blockchain: 'Polygon', + category: 'Public', + dexName: 'DGC', + chainId: '0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f', + decimals: 8, + description: 'Denario Gold Coin', + buyable: false, + sellable: false, + cardBuyable: false, + cardSellable: false, + instantBuyable: false, + instantSellable: false, + paymentEnabled: false, + refEnabled: false, + refundEnabled: true, + ikna: false, + personalIbanEnabled: false, + comingSoon: false, + priceRuleId: null, + }, + { + name: 'DSC', + uniqueName: 'Polygon/DSC', + type: 'Token', + blockchain: 'Polygon', + category: 'Public', + dexName: 'DSC', + chainId: '0x5d4e735784293a0a8d37761ad93c13a0dd35c7e7', + decimals: 8, + description: 'Denario Silver Coin', + buyable: false, + sellable: false, + cardBuyable: false, + cardSellable: false, + instantBuyable: false, + instantSellable: false, + paymentEnabled: false, + refEnabled: false, + refundEnabled: true, + ikna: false, + personalIbanEnabled: false, + comingSoon: false, + priceRuleId: null, + }, +]; + +/** + * Convert driver-specific values such as Date objects into the JSON representation persisted + * in the audit event. + * + * @param {Record} row + * @returns {Record} + */ +function normalizeRow(row) { + return JSON.parse(JSON.stringify(row)); +} + +/** + * Accept a pre-existing asset only when it is exactly the inert token this migration intends to add. + * A matching name alone must never hide a conflicting contract or an accidentally active asset. + * + * @param {QueryRunner} queryRunner + * @param {Record} expected + * @returns {Promise} + */ +async function assertExistingAssetIsExpected(queryRunner, expected) { + const rows = await queryRunner.query(`SELECT * FROM "asset" WHERE "uniqueName" = $1 FOR UPDATE`, [ + expected.uniqueName, + ]); + if (rows.length > 1) throw new Error(`Asset '${expected.uniqueName}' is ambiguous: found ${rows.length} rows`); + if (rows.length === 0) return false; + + const existing = normalizeRow(rows.at(0)); + const mismatches = Object.entries(expected) + .filter(([key, value]) => !isDeepStrictEqual(Reflect.get(existing, key), value)) + .map((entry) => entry.at(0)); + if (mismatches.length > 0) { + throw new Error( + `Asset '${expected.uniqueName}' conflicts with the inert Denario definition in: ${mismatches.join(', ')}`, + ); + } + + return true; +} + +/** + * A pre-existing row is safe to reuse only when it has the same conservative behavior as a row + * created by this migration. In particular, an existing KYC client or auto-approved wallet must + * not silently become the Denario integration merely because its display name matches. + * + * @param {Record} existing + */ +function assertExistingWalletIsExpected(existing) { + const normalized = normalizeRow(existing); + const mismatches = Object.entries(EXPECTED_WALLET) + .filter(([key, value]) => !isDeepStrictEqual(Reflect.get(normalized, key), value)) + .map((entry) => entry.at(0)); + if (mismatches.length > 0) { + throw new Error(`Existing Denario wallet conflicts with the conservative definition in: ${mismatches.join(', ')}`); + } +} + +/** + * @param {QueryRunner} queryRunner + * @returns {Promise<({ id: number } & Record) | undefined>} + */ +async function getActiveApplyAudit(queryRunner) { + await queryRunner.query( + `INSERT INTO "migration_audit_lock" ("migration") VALUES ($1) ON CONFLICT ("migration") DO NOTHING`, + Array.of(AUDIT_MIGRATION), + ); + await queryRunner.query(`SELECT "migration" FROM "migration_audit_lock" WHERE "migration" = $1 FOR UPDATE`, [ + AUDIT_MIGRATION, + ]); + + const rows = await queryRunner.query( + `SELECT "id", "eventType", "applyEventId", "payload" FROM "migration_audit_event" + WHERE "migration" = $1 + ORDER BY "id" FOR UPDATE`, + Array.of(AUDIT_MIGRATION), + ); + + const applies = []; + const rolledBackApplyIds = new Set(); + + for (const row of rows) { + const logId = Number(row.id); + if (!Number.isSafeInteger(logId) || logId <= 0) { + throw new Error(`Invalid audit event id '${row.id}' for ${AUDIT_MIGRATION}`); + } + + let event; + try { + event = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload; + } catch { + throw new Error(`Corrupt audit event ${row.id} for ${AUDIT_MIGRATION}`); + } + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw new Error(`Invalid audit payload ${row.id} for ${AUDIT_MIGRATION}`); + } + + if (row.eventType === 'Apply') { + if ( + event.action !== APPLY_ACTION || + !Array.isArray(event.createdAssets) || + (event.createdWallet != null && (typeof event.createdWallet !== 'object' || Array.isArray(event.createdWallet))) + ) { + throw new Error(`Invalid apply audit event ${logId} for ${AUDIT_MIGRATION}`); + } + applies.push({ ...event, id: logId }); + } else if (row.eventType === 'Rollback') { + const applyEventId = Number(row.applyEventId); + if ( + event.action !== ROLLBACK_ACTION || + !Number.isSafeInteger(applyEventId) || + applyEventId <= 0 || + Number(event.applyLogId) !== applyEventId + ) { + throw new Error(`Invalid rollback audit event ${logId} for ${AUDIT_MIGRATION}`); + } + rolledBackApplyIds.add(applyEventId); + } else { + throw new Error(`Invalid audit event type '${row.eventType}' for ${AUDIT_MIGRATION}`); + } + } + + const activeApplies = applies.filter((event) => !rolledBackApplyIds.has(event.id)); + if (activeApplies.length > 1) { + throw new Error(`Ambiguous audit state for ${AUDIT_MIGRATION}: ${activeApplies.length} active apply events`); + } + + return activeApplies.at(0); +} + +/** + * @param {QueryRunner} queryRunner + * @param {Record} event + * @returns {Promise} + */ +async function writeAuditEvent(queryRunner, event) { + const isApply = event.action === APPLY_ACTION; + const isRollback = event.action === ROLLBACK_ACTION; + if (!isApply && !isRollback) throw new Error(`Invalid audit action for ${AUDIT_MIGRATION}`); + + await queryRunner.query( + `INSERT INTO "migration_audit_event" ("migration", "eventType", "applyEventId", "payload") + VALUES ($1, $2, $3, $4::jsonb)`, + [AUDIT_MIGRATION, isApply ? 'Apply' : 'Rollback', isRollback ? event.applyLogId : null, JSON.stringify(event)], + ); +} + +/** + * Load an owned row by its exact id and fail closed if anything has changed since this migration + * created it. Missing rows are already absent and therefore need no destructive rollback action. + * + * @param {QueryRunner} queryRunner + * @param {'asset' | 'wallet'} table + * @param {Record} snapshot + * @returns {Promise} + */ +async function assertOwnedRowIsUnchanged(queryRunner, table, snapshot) { + const id = Number(snapshot?.id); + if (!Number.isSafeInteger(id) || id <= 0) { + throw new Error(`Invalid ${table} ownership snapshot in ${AUDIT_MIGRATION}`); + } + + const current = (await queryRunner.query(`SELECT * FROM "${table}" WHERE "id" = $1 FOR UPDATE`, Array.of(id))).at(0); + if (!current) return false; + + if (!isDeepStrictEqual(normalizeRow(current), snapshot)) { + throw new Error(`${table} row ${id} changed since creation; refusing destructive rollback`); + } + + return true; +} + /** * @class * @implements {MigrationInterface} @@ -34,58 +280,144 @@ module.exports = class AddDenarioWalletAndAssets1784038000000 { * @param {QueryRunner} queryRunner */ async up(queryRunner) { - // Partner wallet — idempotent on name; all other columns take their DB defaults. - await queryRunner.query( - `INSERT INTO "wallet" ("name", "displayName") - SELECT 'Denario', 'Denario' - WHERE NOT EXISTS (SELECT 1 FROM "wallet" WHERE "name" = 'Denario')`, + // TypeORM can call a migration object more than once in tests or recovery tooling. An unmatched apply + // event proves this migration already owns its exact inserts, so a repeated up() is a safe no-op. + if (await getActiveApplyAudit(queryRunner)) return; + + const preExistingWallets = await queryRunner.query( + `SELECT "id" FROM "wallet" WHERE "name" = 'Denario' ORDER BY "id"`, ); + if (preExistingWallets.length > 1) { + throw new Error(`Denario wallet is ambiguous: found ${preExistingWallets.length} matching rows`); + } - // Inert, list-only assets — idempotent on the stable uniqueName (ids are env-specific). + // wallet.name is intentionally not globally unique, but this integration requires one stable Denario + // partner row. The partial unique index is the database-level guard that also closes the empty-result + // SELECT/INSERT race against the admin wallet endpoint. Re-read after creating it because an external + // insert may have committed between the preflight query and the index lock. await queryRunner.query( - `INSERT INTO "asset" - ("name", "uniqueName", "type", "blockchain", "category", "dexName", "chainId", "decimals", "description", - "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", - "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon") - SELECT 'DGC', 'Polygon/DGC', 'Token', 'Polygon', 'Public', 'DGC', '0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f', 8, 'Denario Gold Coin', - false, false, false, false, false, false, - false, false, true, false, false, false - WHERE NOT EXISTS (SELECT 1 FROM "asset" WHERE "uniqueName" = 'Polygon/DGC')`, + `CREATE UNIQUE INDEX "${DENARIO_WALLET_NAME_INDEX}" ON "wallet" ("name") WHERE "name" = 'Denario'`, ); - await queryRunner.query( - `INSERT INTO "asset" - ("name", "uniqueName", "type", "blockchain", "category", "dexName", "chainId", "decimals", "description", - "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", - "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon") - SELECT 'DSC', 'Polygon/DSC', 'Token', 'Polygon', 'Public', 'DSC', '0x5d4e735784293a0a8d37761ad93c13a0dd35c7e7', 8, 'Denario Silver Coin', - false, false, false, false, false, false, - false, false, true, false, false, false - WHERE NOT EXISTS (SELECT 1 FROM "asset" WHERE "uniqueName" = 'Polygon/DSC')`, + const existingWallets = await queryRunner.query( + `SELECT * FROM "wallet" WHERE "name" = 'Denario' ORDER BY "id" FOR UPDATE`, ); + if (existingWallets.length > 1) { + throw new Error(`Denario wallet is ambiguous: found ${existingWallets.length} matching rows`); + } + if (existingWallets.length === 1) assertExistingWalletIsExpected(existingWallets.at(0)); + + // Partner wallet — all other columns take their conservative DB defaults. + const createdWallet = existingWallets.length + ? undefined + : ( + await queryRunner.query( + `INSERT INTO "wallet" ("name", "displayName") VALUES ('Denario', 'Denario') RETURNING *`, + ) + ).at(0); + + const [dgcExpected, dscExpected] = EXPECTED_ASSETS; + const dgcExists = await assertExistingAssetIsExpected(queryRunner, dgcExpected); + const dscExists = await assertExistingAssetIsExpected(queryRunner, dscExpected); + + // Inert, list-only assets — existing rows were fully validated above; ids remain environment-specific. + const createdDgc = dgcExists + ? undefined + : ( + await queryRunner.query( + `INSERT INTO "asset" + ("name", "uniqueName", "type", "blockchain", "category", "dexName", "chainId", "decimals", "description", + "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon") + VALUES ('DGC', 'Polygon/DGC', 'Token', 'Polygon', 'Public', 'DGC', '0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f', 8, 'Denario Gold Coin', + false, false, false, false, false, false, + false, false, true, false, false, false) + RETURNING *`, + ) + ).at(0); + + const createdDsc = dscExists + ? undefined + : ( + await queryRunner.query( + `INSERT INTO "asset" + ("name", "uniqueName", "type", "blockchain", "category", "dexName", "chainId", "decimals", "description", + "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon") + VALUES ('DSC', 'Polygon/DSC', 'Token', 'Polygon', 'Public', 'DSC', '0x5d4e735784293a0a8d37761ad93c13a0dd35c7e7', 8, 'Denario Silver Coin', + false, false, false, false, false, false, + false, false, true, false, false, false) + RETURNING *`, + ) + ).at(0); + + // Persist exact IDs and complete created-row snapshots. Pre-existing matching rows are intentionally + // absent from this event and can therefore never be deleted by down(). Migration execution is + // transactional, so the inserts and their ownership event commit together or not at all. + await writeAuditEvent(queryRunner, { + action: APPLY_ACTION, + createdWallet: createdWallet ? normalizeRow(createdWallet) : null, + createdAssets: [createdDgc, createdDsc].filter(Boolean).map(normalizeRow), + }); } /** * @param {QueryRunner} queryRunner */ async down(queryRunner) { - // Revert only the state this migration owns. up() inserts are NOT EXISTS-guarded (the loc seed can - // create the same rows), so down() must not delete rows it may not have created: remove the assets - // only while they are still inert (no price rule, not tradeable) and the wallet only while it has no - // owner and no attached users. Anything activated or linked afterwards is left in place (roll-forward), - // so a rollback never destroys pre-existing or accumulated data. - await queryRunner.query( - `DELETE FROM "asset" - WHERE "uniqueName" IN ('Polygon/DGC', 'Polygon/DSC') - AND "priceRuleId" IS NULL - AND "buyable" = false - AND "sellable" = false`, - ); - await queryRunner.query( - `DELETE FROM "wallet" - WHERE "name" = 'Denario' - AND "ownerId" IS NULL - AND "id" NOT IN (SELECT "walletId" FROM "user" WHERE "walletId" IS NOT NULL)`, - ); + const applyAudit = await getActiveApplyAudit(queryRunner); + if (!applyAudit) return; + + // Remove the table-level uniqueness guard before taking any wallet row lock. If an admin UPDATE + // already holds ROW EXCLUSIVE and then waits for the same wallet row, doing this after the ownership + // read creates a lock cycle (row -> table versus table -> row). TypeORM runs migrations in one + // transaction, so a later validation or delete failure restores the index automatically. + await queryRunner.query(`DROP INDEX "${DENARIO_WALLET_NAME_INDEX}"`); + + const createdAssets = applyAudit.createdAssets; + if (!Array.isArray(createdAssets)) { + throw new Error(`Invalid asset ownership audit in ${AUDIT_MIGRATION}`); + } + + const assetsToDelete = []; + for (const snapshot of createdAssets) { + if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { + throw new Error(`Invalid asset ownership snapshot in ${AUDIT_MIGRATION}`); + } + if (await assertOwnedRowIsUnchanged(queryRunner, 'asset', snapshot)) assetsToDelete.push(Number(snapshot.id)); + } + + const createdWallet = applyAudit.createdWallet; + if (createdWallet != null && (typeof createdWallet !== 'object' || Array.isArray(createdWallet))) { + throw new Error(`Invalid wallet ownership snapshot in ${AUDIT_MIGRATION}`); + } + + let walletToDelete; + if (createdWallet && (await assertOwnedRowIsUnchanged(queryRunner, 'wallet', createdWallet))) { + walletToDelete = Number(createdWallet.id); + const references = await queryRunner.query( + `SELECT COUNT(*)::int AS "count" FROM "user" WHERE "walletId" = $1`, + Array.of(walletToDelete), + ); + if (Number(references.at(0).count) > 0) { + throw new Error(`wallet row ${walletToDelete} has attached users; refusing destructive rollback`); + } + } + + // Record exactly what this rollback is about to delete before the destructive writes. The append-only + // apply and rollback events survive the revert and make every ownership decision reconstructible. + await writeAuditEvent(queryRunner, { + action: ROLLBACK_ACTION, + applyLogId: applyAudit.id, + deletedAssetIds: assetsToDelete, + deletedWalletId: walletToDelete ?? null, + }); + + for (const id of assetsToDelete) { + await queryRunner.query(`DELETE FROM "asset" WHERE "id" = $1`, Array.of(id)); + } + if (walletToDelete) { + await queryRunner.query(`DELETE FROM "wallet" WHERE "id" = $1`, Array.of(walletToDelete)); + } } }; diff --git a/migration/1784039000000-LinkOndoPriceRule.js b/migration/1784039000000-LinkOndoPriceRule.js new file mode 100644 index 0000000000..883d00c37c --- /dev/null +++ b/migration/1784039000000-LinkOndoPriceRule.js @@ -0,0 +1,240 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +const ONDO_UNIQUE_NAME = 'Ethereum/ONDO'; +const AUDIT_MIGRATION = 'LinkOndoPriceRule1784039000000'; +const APPLY_ACTION = 'applyOndoPriceRule'; +const ROLLBACK_ACTION = 'rollbackOndoPriceRule'; + +/** + * @param {QueryRunner} queryRunner + * @returns {Promise<({ id: number, assetId: number, nextPriceRuleId: number } & Record) | undefined>} + */ +async function getActiveApplyAudit(queryRunner) { + await queryRunner.query( + `INSERT INTO "migration_audit_lock" ("migration") VALUES ($1) ON CONFLICT ("migration") DO NOTHING`, + Array.of(AUDIT_MIGRATION), + ); + await queryRunner.query(`SELECT "migration" FROM "migration_audit_lock" WHERE "migration" = $1 FOR UPDATE`, [ + AUDIT_MIGRATION, + ]); + + const rows = await queryRunner.query( + `SELECT "id", "eventType", "applyEventId", "payload" FROM "migration_audit_event" + WHERE "migration" = $1 + ORDER BY "id" FOR UPDATE`, + Array.of(AUDIT_MIGRATION), + ); + + const applies = []; + const rolledBackApplyIds = new Set(); + + for (const row of rows) { + const logId = Number(row.id); + if (!Number.isSafeInteger(logId) || logId <= 0) { + throw new Error(`Invalid audit event id '${row.id}' for ${AUDIT_MIGRATION}`); + } + + let event; + try { + event = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload; + } catch { + throw new Error(`Corrupt audit event ${logId} for ${AUDIT_MIGRATION}`); + } + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw new Error(`Invalid audit payload ${logId} for ${AUDIT_MIGRATION}`); + } + + if (row.eventType === 'Apply') { + const assetId = Number(event.assetId); + const nextPriceRuleId = Number(event.nextPriceRuleId); + if ( + event.action !== APPLY_ACTION || + event.uniqueName !== ONDO_UNIQUE_NAME || + event.previousPriceRuleId !== null || + !Number.isSafeInteger(assetId) || + assetId <= 0 || + !Number.isSafeInteger(nextPriceRuleId) || + nextPriceRuleId <= 0 + ) { + throw new Error(`Invalid apply audit event ${logId} for ${AUDIT_MIGRATION}`); + } + applies.push({ ...event, id: logId, assetId, nextPriceRuleId }); + } else if (row.eventType === 'Rollback') { + const applyEventId = Number(row.applyEventId); + if ( + event.action !== ROLLBACK_ACTION || + !Number.isSafeInteger(applyEventId) || + applyEventId <= 0 || + Number(event.applyLogId) !== applyEventId + ) { + throw new Error(`Invalid rollback audit event ${logId} for ${AUDIT_MIGRATION}`); + } + rolledBackApplyIds.add(applyEventId); + } else { + throw new Error(`Invalid audit event type '${row.eventType}' for ${AUDIT_MIGRATION}`); + } + } + + const activeApplies = applies.filter((event) => !rolledBackApplyIds.has(event.id)); + if (activeApplies.length > 1) { + throw new Error(`Ambiguous audit state for ${AUDIT_MIGRATION}: ${activeApplies.length} active apply events`); + } + + return activeApplies.at(0); +} + +/** + * @param {QueryRunner} queryRunner + * @param {Record} event + * @returns {Promise} + */ +async function writeAuditEvent(queryRunner, event) { + const isApply = event.action === APPLY_ACTION; + const isRollback = event.action === ROLLBACK_ACTION; + if (!isApply && !isRollback) throw new Error(`Invalid audit action for ${AUDIT_MIGRATION}`); + + await queryRunner.query( + `INSERT INTO "migration_audit_event" ("migration", "eventType", "applyEventId", "payload") + VALUES ($1, $2, $3, $4::jsonb)`, + [AUDIT_MIGRATION, isApply ? 'Apply' : 'Rollback', isRollback ? event.applyLogId : null, JSON.stringify(event)], + ); +} + +/** + * Resolve the environment-local rule by its semantic identity instead of assuming that seed id 60 + * is stable outside local development. + * + * @param {QueryRunner} queryRunner + * @returns {Promise} + */ +async function resolveOndoPriceRuleId(queryRunner) { + const rows = await queryRunner.query( + `SELECT "id" FROM "price_rule" + WHERE "priceSource" = 'CoinGecko' + AND "priceAsset" = 'ondo-finance' + AND "priceReference" = 'tether' + ORDER BY "id" FOR UPDATE`, + ); + + if (rows.length !== 1) { + throw new Error(`Expected exactly one ONDO price rule, found ${rows.length}`); + } + + const id = Number(rows.at(0).id); + if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`Invalid ONDO price rule id '${rows.at(0).id}'`); + return id; +} + +/** + * Links the existing active ONDO asset to its existing semantic price rule. This is required because + * generic pay-in register strategies intentionally reject unpriced assets before creating processable inputs. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class LinkOndoPriceRule1784039000000 { + name = 'LinkOndoPriceRule1784039000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + if (await getActiveApplyAudit(queryRunner)) return; + + const assets = await queryRunner.query( + `SELECT "id", "priceRuleId" FROM "asset" WHERE "uniqueName" = $1 FOR UPDATE`, + Array.of(ONDO_UNIQUE_NAME), + ); + if (assets.length === 0) throw new Error(`Required ONDO asset '${ONDO_UNIQUE_NAME}' was not found`); + if (assets.length > 1) throw new Error(`ONDO asset is ambiguous: found ${assets.length} matching rows`); + + const assetId = Number(assets.at(0).id); + if (!Number.isSafeInteger(assetId) || assetId <= 0) throw new Error(`Invalid ONDO asset id '${assets.at(0).id}'`); + + const priceRuleId = await resolveOndoPriceRuleId(queryRunner); + const currentPriceRuleId = assets.at(0).priceRuleId == null ? null : Number(assets.at(0).priceRuleId); + if (currentPriceRuleId != null) { + if (currentPriceRuleId === priceRuleId) return; + throw new Error( + `ONDO already references price rule ${currentPriceRuleId}, expected semantic rule ${priceRuleId}; refusing overwrite`, + ); + } + + // Persist the exact before -> after transition before changing the mutable asset snapshot. + await writeAuditEvent(queryRunner, { + action: APPLY_ACTION, + assetId, + uniqueName: ONDO_UNIQUE_NAME, + previousPriceRuleId: null, + nextPriceRuleId: priceRuleId, + }); + + await queryRunner.query( + `UPDATE "asset" SET "priceRuleId" = $1, "updated" = NOW() + WHERE "id" = $2 AND "priceRuleId" IS NULL`, + [priceRuleId, assetId], + ); + const after = (await queryRunner.query(`SELECT "priceRuleId" FROM "asset" WHERE "id" = $1`, Array.of(assetId))).at( + 0, + ); + if (Number(after?.priceRuleId) !== priceRuleId) { + throw new Error('ONDO price-rule assignment changed concurrently; migration aborted'); + } + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + const applyAudit = await getActiveApplyAudit(queryRunner); + if (!applyAudit) return; + + const asset = ( + await queryRunner.query(`SELECT "uniqueName", "priceRuleId" FROM "asset" WHERE "id" = $1 FOR UPDATE`, [ + applyAudit.assetId, + ]) + ).at(0); + const currentPriceRuleId = asset?.priceRuleId == null ? null : Number(asset.priceRuleId); + + let outcome = 'skippedAssetMissing'; + let nextPriceRuleId = currentPriceRuleId; + if (asset) { + if (asset.uniqueName !== ONDO_UNIQUE_NAME) { + outcome = 'skippedAssetIdentityChanged'; + } else if (currentPriceRuleId === applyAudit.nextPriceRuleId) { + outcome = 'reverted'; + nextPriceRuleId = null; + } else { + outcome = 'skippedPriceRuleChanged'; + } + } + + await writeAuditEvent(queryRunner, { + action: ROLLBACK_ACTION, + applyLogId: applyAudit.id, + assetId: applyAudit.assetId, + expectedUniqueName: ONDO_UNIQUE_NAME, + currentUniqueName: asset?.uniqueName ?? null, + previousPriceRuleId: currentPriceRuleId, + nextPriceRuleId, + outcome, + }); + + if (outcome !== 'reverted') return; + + await queryRunner.query( + `UPDATE "asset" SET "priceRuleId" = NULL, "updated" = NOW() + WHERE "id" = $1 AND "uniqueName" = $2 AND "priceRuleId" = $3`, + [applyAudit.assetId, ONDO_UNIQUE_NAME, applyAudit.nextPriceRuleId], + ); + const after = ( + await queryRunner.query(`SELECT "uniqueName", "priceRuleId" FROM "asset" WHERE "id" = $1`, [applyAudit.assetId]) + ).at(0); + if (!after || after.uniqueName !== ONDO_UNIQUE_NAME || after.priceRuleId != null) { + throw new Error('ONDO price-rule rollback changed concurrently; migration aborted'); + } + } +}; diff --git a/migration/seed/asset.csv b/migration/seed/asset.csv index d5ec606606..127eec6d2d 100644 --- a/migration/seed/asset.csv +++ b/migration/seed/asset.csv @@ -9,7 +9,7 @@ id,name,type,buyable,sellable,chainId,sellCommand,dexName,category,blockchain,un 401,EUR,Custody,FALSE,FALSE,,,EUR,Private,Scrypt,Scrypt/EUR,,FALSE,,1.17786809,FALSE,39,0.9287514723,FALSE,FALSE,FALSE,FALSE,EUR,,FALSE,0,0,1,TRUE 400,CHF,Custody,FALSE,FALSE,,,CHF,Private,Scrypt,Scrypt/CHF,,FALSE,,1.268227427,FALSE,37,1,FALSE,FALSE,FALSE,FALSE,CHF,,FALSE,0,0,1.076714309,TRUE 399,REALU,Token,FALSE,FALSE,0x553c7f9c780316fc1d34b8e14ac2465ab22a090b,,REALU,Public,Ethereum,Ethereum/REALU,RealUnit Shares,FALSE,99,1.711564371,FALSE,61,1.349572115,FALSE,FALSE,FALSE,FALSE,Other,0,FALSE,0,0,1.453103607,TRUE -398,ONDO,Token,TRUE,TRUE,0xfaba6f8e4a5e8ab82f62fe7c39859fa577269be3,,ONDO,Public,Ethereum,Ethereum/ONDO,Ondo Finance,FALSE,99,,FALSE,,,FALSE,FALSE,FALSE,FALSE,Other,18,FALSE,0,0,,TRUE +398,ONDO,Token,TRUE,TRUE,0xfaba6f8e4a5e8ab82f62fe7c39859fa577269be3,,ONDO,Public,Ethereum,Ethereum/ONDO,Ondo Finance,FALSE,99,,FALSE,60,,FALSE,FALSE,FALSE,FALSE,Other,18,FALSE,0,0,,TRUE 397,fUSD,Token,TRUE,TRUE,86143388bd056a8f0bab669f78f14873fac8e2dd8d57898cdb725a2d5e2e4f8f,,fUSD,Public,Zano,Zano/fUSD,Freedom Dollar,FALSE,,1,FALSE,59,0.7885021082,FALSE,FALSE,FALSE,FALSE,USD,4,FALSE,0,0,0.8489915028,TRUE 396,XMR,Custody,FALSE,FALSE,,,XMR,Private,MEXC,MEXC/XMR,,FALSE,,440.0844046,FALSE,10,347.0074808,FALSE,FALSE,FALSE,FALSE,Other,,FALSE,0,0,373.62792,FALSE 395,ZCHF,Custody,FALSE,FALSE,,,ZCHF,Private,MEXC,MEXC/ZCHF,,FALSE,,1.267825458,FALSE,2,1,FALSE,FALSE,FALSE,FALSE,CHF,,FALSE,0,0,1.076373041,FALSE diff --git a/src/shared/models/asset/asset.service.spec.ts b/src/shared/models/asset/asset.service.spec.ts index be86c63612..08ed3b20ab 100644 --- a/src/shared/models/asset/asset.service.spec.ts +++ b/src/shared/models/asset/asset.service.spec.ts @@ -43,15 +43,12 @@ describe('AssetService', () => { expect(options.where).toMatchObject({ priceRule: Not(IsNull()) }); }); - it('does not recognize an inbound deposit of a token missing from the priced pay-in set, so it cannot loop the pay-in', async () => { - // the DB filter already dropped the unpriced DGC, so only ZCHF comes back + it('uses only the DB-filtered set for chain-id matching', async () => { jest.spyOn(assetRepo, 'findCached').mockResolvedValue([zchf]); const payInAssets = await service.getPayInAssets([Blockchain.POLYGON]); - // priced token stays recognizable expect(service.getByChainIdSync(payInAssets, Blockchain.POLYGON, zchf.chainId)).toBe(zchf); - // inert DGC is not recognized -> mapped asset is undefined -> CryptoInput gets status FAILED (no retry loop) expect(service.getByChainIdSync(payInAssets, Blockchain.POLYGON, dgcChainId)).toBeUndefined(); }); }); diff --git a/src/shared/models/asset/tests/add-denario-wallet-and-assets.migration.spec.ts b/src/shared/models/asset/tests/add-denario-wallet-and-assets.migration.spec.ts index 6c5be772de..1ac1d8f785 100644 --- a/src/shared/models/asset/tests/add-denario-wallet-and-assets.migration.spec.ts +++ b/src/shared/models/asset/tests/add-denario-wallet-and-assets.migration.spec.ts @@ -18,6 +18,27 @@ async function countWallet(queryRunner: QueryRunner): Promise { return rows.at(0).c; } +async function insertInertAsset(queryRunner: QueryRunner, uniqueName: 'Polygon/DGC' | 'Polygon/DSC'): Promise { + const isDgc = uniqueName === 'Polygon/DGC'; + const rows = await queryRunner.query( + `INSERT INTO "asset" + ("name", "uniqueName", "type", "blockchain", "category", "dexName", "chainId", "decimals", "description", + "buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon") + VALUES ($1, $2, 'Token', 'Polygon', 'Public', $1, $3, 8, $4, + false, false, false, false, false, false, + false, false, true, false, false, false) + RETURNING "id"`, + [ + isDgc ? 'DGC' : 'DSC', + uniqueName, + isDgc ? '0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f' : '0x5d4e735784293a0a8d37761ad93c13a0dd35c7e7', + isDgc ? 'Denario Gold Coin' : 'Denario Silver Coin', + ], + ); + return rows.at(0).id; +} + describe('AddDenarioWalletAndAssets migration (postgres semantics)', () => { let dataSource: DataSource; let queryRunner: QueryRunner; @@ -65,12 +86,40 @@ describe('AddDenarioWalletAndAssets migration (postgres semantics)', () => { await dataSource.query(` CREATE TABLE "wallet" ( "id" serial PRIMARY KEY, + "address" text, "name" text, "displayName" text, + "isKycClient" boolean NOT NULL DEFAULT false, + "displayFraudWarning" boolean NOT NULL DEFAULT false, + "usesDummyAddresses" boolean NOT NULL DEFAULT false, + "customKyc" text, + "identMethod" text, + "apiUrl" text, + "apiKey" text, + "amlRules" text NOT NULL DEFAULT '0', + "exceptAmlRules" text, + "webhookConfig" text, + "mailConfig" text, + "autoTradeApproval" boolean NOT NULL DEFAULT false, + "buySpecificIbanEnabled" boolean NOT NULL DEFAULT false, "ownerId" integer ) `); await dataSource.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY, "walletId" integer)`); + await dataSource.query(` + CREATE TABLE "migration_audit_lock" ( + "migration" text PRIMARY KEY + ) + `); + await dataSource.query(` + CREATE TABLE "migration_audit_event" ( + "id" serial PRIMARY KEY, + "migration" text NOT NULL, + "eventType" text NOT NULL, + "applyEventId" integer UNIQUE, + "payload" jsonb NOT NULL + ) + `); queryRunner = dataSource.createQueryRunner(); await queryRunner.connect(); @@ -97,6 +146,13 @@ describe('AddDenarioWalletAndAssets migration (postgres semantics)', () => { priceRuleId: null, }); expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(1); + const audit = await queryRunner.query(`SELECT "payload" FROM "migration_audit_event" ORDER BY "id"`); + const apply = audit.at(0).payload; + expect(apply.createdWallet.id).toEqual(expect.any(Number)); + expect(apply.createdAssets.map((asset: { uniqueName: string }) => asset.uniqueName)).toEqual([ + 'Polygon/DGC', + 'Polygon/DSC', + ]); }); it('is idempotent — a second up() does not duplicate rows', async () => { @@ -106,33 +162,181 @@ describe('AddDenarioWalletAndAssets migration (postgres semantics)', () => { expect(await countWallet(queryRunner)).toBe(1); expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(1); expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(1); + expect(await queryRunner.query(`SELECT "id" FROM "migration_audit_event"`)).toHaveLength(1); }); - it('down() removes the inert assets and the unused wallet it created', async () => { + it('keeps the Denario partner name unique while the migration is active', async () => { await new AddDenarioWalletAndAssets().up(queryRunner); - await new AddDenarioWalletAndAssets().down(queryRunner); + await expect( + queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Denario', 'Denario')`), + ).rejects.toThrow(); + + expect(await countWallet(queryRunner)).toBe(1); + }); + + it('does not make other wallet names unique', async () => { + await new AddDenarioWalletAndAssets().up(queryRunner); + + await queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Other', 'First')`); + await queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Other', 'Second')`); + + // pg-mem incorrectly answers a WHERE name='Other' lookup from this partial index, so inspect + // the full table here. Real PostgreSQL coverage separately verifies the predicate semantics. + const wallets = await queryRunner.query(`SELECT "id", "name" FROM "wallet"`); + expect(wallets.filter((wallet) => wallet.name === 'Other')).toHaveLength(2); + }); + + it('down() removes the inert assets and the unused wallet it created', async () => { + const migration = new AddDenarioWalletAndAssets(); + await migration.up(queryRunner); + + const statements: string[] = []; + const originalQuery = queryRunner.query.bind(queryRunner); + const querySpy = jest.spyOn(queryRunner, 'query').mockImplementation((query, parameters) => { + statements.push(query); + return originalQuery(query, parameters); + }); + + try { + await migration.down(queryRunner); + } finally { + querySpy.mockRestore(); + } expect(await countWallet(queryRunner)).toBe(0); expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(0); expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(0); + const events = await queryRunner.query(`SELECT "payload" FROM "migration_audit_event" ORDER BY "id"`); + expect(events.map((row) => row.payload.action)).toEqual([ + 'applyDenarioWalletAndAssets', + 'rollbackDenarioWalletAndAssets', + ]); + + const dropIndexAt = statements.findIndex((sql) => sql.includes('DROP INDEX')); + const walletRowLockAt = statements.findIndex((sql) => sql.includes('SELECT * FROM "wallet"')); + expect(dropIndexAt).toBeGreaterThanOrEqual(0); + expect(walletRowLockAt).toBeGreaterThan(dropIndexAt); + }); + + it('up() -> down() preserves pre-existing rows by exact ownership', async () => { + const preExistingDgcId = await insertInertAsset(queryRunner, 'Polygon/DGC'); + await queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Denario', 'Denario')`); + + const migration = new AddDenarioWalletAndAssets(); + await migration.up(queryRunner); + + expect(await countWallet(queryRunner)).toBe(1); + expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(1); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(1); + + await migration.down(queryRunner); + + expect(await countWallet(queryRunner)).toBe(1); + expect(await queryRunner.query(`SELECT "id" FROM "asset" WHERE "uniqueName" = 'Polygon/DGC'`)).toEqual([ + { id: preExistingDgcId }, + ]); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(0); + }); + + it('fails closed and preserves every row when duplicate Denario wallet names already exist', async () => { + await queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Denario', 'Denario')`); + await queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Denario', 'Denario')`); + + await expect(new AddDenarioWalletAndAssets().up(queryRunner)).rejects.toThrow('ambiguous'); + + expect(await countWallet(queryRunner)).toBe(2); + expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(0); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(0); + expect(await queryRunner.query(`SELECT "id" FROM "migration_audit_event"`)).toEqual([]); + }); + + it.each([ + ['isKycClient', 'true'], + ['autoTradeApproval', 'true'], + ['usesDummyAddresses', 'true'], + ['displayFraudWarning', 'true'], + ['amlRules', "'1'"], + ['ownerId', '7'], + ])('fails closed when a pre-existing Denario wallet conflicts via %s', async (column, value) => { + await queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Denario', 'Denario')`); + await queryRunner.query(`UPDATE "wallet" SET "${column}" = ${value} WHERE "name" = 'Denario'`); + + await expect(new AddDenarioWalletAndAssets().up(queryRunner)).rejects.toThrow(column); + + expect(await countWallet(queryRunner)).toBe(1); + expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(0); + expect(await queryRunner.query(`SELECT "id" FROM "migration_audit_event"`)).toEqual([]); + }); + + it('fails closed when a pre-existing Denario asset conflicts with the inert definition', async () => { + await insertInertAsset(queryRunner, 'Polygon/DGC'); + await queryRunner.query(`UPDATE "asset" SET "decimals" = 18 WHERE "uniqueName" = 'Polygon/DGC'`); + + await expect(new AddDenarioWalletAndAssets().up(queryRunner)).rejects.toThrow('decimals'); + + expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(1); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(0); }); - it('down() preserves assets that were activated and a wallet that has users attached', async () => { + it.each([ + 'buyable', + 'sellable', + 'cardBuyable', + 'cardSellable', + 'instantBuyable', + 'instantSellable', + 'paymentEnabled', + 'refEnabled', + ])('down() fails closed when an owned asset changed via %s', async (flag) => { + await new AddDenarioWalletAndAssets().up(queryRunner); + + await queryRunner.query(`UPDATE "asset" SET "${flag}" = true WHERE "uniqueName" = 'Polygon/DSC'`); + + await expect(new AddDenarioWalletAndAssets().down(queryRunner)).rejects.toThrow('changed since creation'); + expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(1); + expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(1); + expect(await countWallet(queryRunner)).toBe(1); + expect(await queryRunner.query(`SELECT "id" FROM "migration_audit_event"`)).toHaveLength(1); + }); + + it('down() fails closed when an owned wallet has users attached', async () => { await new AddDenarioWalletAndAssets().up(queryRunner); - // DGC gets a price rule, DSC is made buyable (i.e. later activated for trading) - await queryRunner.query(`UPDATE "asset" SET "priceRuleId" = 1 WHERE "uniqueName" = 'Polygon/DGC'`); - await queryRunner.query(`UPDATE "asset" SET "buyable" = true WHERE "uniqueName" = 'Polygon/DSC'`); - // a user is linked to the Denario wallet const walletId = (await queryRunner.query(`SELECT "id" FROM "wallet" WHERE "name" = 'Denario'`)).at(0).id; await queryRunner.query(`INSERT INTO "user" ("id", "walletId") VALUES (1, ${walletId})`); - await new AddDenarioWalletAndAssets().down(queryRunner); - - // nothing that gained real state is destroyed (roll-forward) + await expect(new AddDenarioWalletAndAssets().down(queryRunner)).rejects.toThrow('attached users'); expect(await countAsset(queryRunner, 'Polygon/DGC')).toBe(1); expect(await countAsset(queryRunner, 'Polygon/DSC')).toBe(1); expect(await countWallet(queryRunner)).toBe(1); }); + + it('supports two apply/rollback cycles without deleting audit history', async () => { + const migration = new AddDenarioWalletAndAssets(); + + await migration.up(queryRunner); + await migration.down(queryRunner); + await migration.up(queryRunner); + await migration.down(queryRunner); + + const events = await queryRunner.query(`SELECT "payload" FROM "migration_audit_event" ORDER BY "id"`); + expect(events.map((row) => row.payload.action)).toEqual([ + 'applyDenarioWalletAndAssets', + 'rollbackDenarioWalletAndAssets', + 'applyDenarioWalletAndAssets', + 'rollbackDenarioWalletAndAssets', + ]); + }); + + it('removes the Denario-only uniqueness guard when the migration is reverted', async () => { + const migration = new AddDenarioWalletAndAssets(); + await migration.up(queryRunner); + await migration.down(queryRunner); + + await queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Denario', 'Denario')`); + await queryRunner.query(`INSERT INTO "wallet" ("name", "displayName") VALUES ('Denario', 'Denario')`); + + expect(await countWallet(queryRunner)).toBe(2); + }); }); diff --git a/src/shared/models/asset/tests/asset-seed.spec.ts b/src/shared/models/asset/tests/asset-seed.spec.ts new file mode 100644 index 0000000000..a3337facc9 --- /dev/null +++ b/src/shared/models/asset/tests/asset-seed.spec.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; + +type CsvRow = Record; + +const GENERIC_PRICED_PAY_IN_BLOCKCHAINS = new Set([ + Blockchain.ARBITRUM, + Blockchain.BASE, + Blockchain.BINANCE_SMART_CHAIN, + Blockchain.CARDANO, + Blockchain.CITREA, + Blockchain.ETHEREUM, + Blockchain.GNOSIS, + Blockchain.INTERNET_COMPUTER, + Blockchain.OPTIMISM, + Blockchain.POLYGON, + Blockchain.SEPOLIA, + Blockchain.SOLANA, + Blockchain.TRON, + Blockchain.ZANO, + Blockchain.BINANCE_PAY, + Blockchain.KUCOIN_PAY, +]); + +const ACTIVE_FLAGS = [ + 'buyable', + 'sellable', + 'cardBuyable', + 'cardSellable', + 'instantBuyable', + 'instantSellable', + 'paymentEnabled', + 'refEnabled', +]; + +function parseCsvLine(line: string): string[] { + const values: string[] = []; + let value = ''; + let quoted = false; + + for (const char of line) { + if (char === '"') { + quoted = !quoted; + } else if (char === ',' && !quoted) { + values.push(value.trim()); + value = ''; + } else { + value += char; + } + } + values.push(value.trim()); + return values; +} + +function readCsv(fileName: string): CsvRow[] { + const lines = fs + .readFileSync(path.join(process.cwd(), 'migration/seed', fileName), 'utf8') + .split(/\r?\n/) + .filter(Boolean); + const headers = parseCsvLine(lines.at(0)!); + + return lines.slice(1).map((line) => { + const values = parseCsvLine(line); + expect(values).toHaveLength(headers.length); + return Object.fromEntries(headers.map((header, index) => [header, values[index]])); + }); +} + +function isActive(asset: CsvRow): boolean { + return ACTIVE_FLAGS.some((flag) => asset[flag] === 'TRUE'); +} + +describe('asset seed pricing invariants', () => { + const assets = readCsv('asset.csv'); + const priceRules = readCsv('price_rule.csv'); + const priceRulesById = new Map(priceRules.map((rule) => [rule.id, rule])); + + it('links ONDO to the existing local semantic price rule', () => { + const ondo = assets.find((asset) => asset.uniqueName === 'Ethereum/ONDO'); + + expect(ondo?.priceRuleId).toBe('60'); + expect(priceRulesById.get(ondo!.priceRuleId)).toMatchObject({ + priceSource: 'CoinGecko', + priceAsset: 'ondo-finance', + priceReference: 'tether', + }); + }); + + it('references only price-rule ids that exist in the seed', () => { + const missingRules = assets + .filter((asset) => asset.priceRuleId && !priceRulesById.has(asset.priceRuleId)) + .map((asset) => `${asset.uniqueName}:${asset.priceRuleId}`); + + expect(missingRules).toEqual([]); + }); + + it('prices every active seed asset handled by a generic priced pay-in strategy', () => { + const activeUnpricedAssets = assets + .filter( + (asset) => GENERIC_PRICED_PAY_IN_BLOCKCHAINS.has(asset.blockchain) && isActive(asset) && !asset.priceRuleId, + ) + .map((asset) => asset.uniqueName); + + expect(activeUnpricedAssets).toEqual([]); + }); +}); diff --git a/src/shared/models/asset/tests/link-ondo-price-rule.migration.spec.ts b/src/shared/models/asset/tests/link-ondo-price-rule.migration.spec.ts new file mode 100644 index 0000000000..15062763bd --- /dev/null +++ b/src/shared/models/asset/tests/link-ondo-price-rule.migration.spec.ts @@ -0,0 +1,244 @@ +import { DataType, newDb } from 'pg-mem'; +import { DataSource, QueryRunner } from 'typeorm'; + +type Migration = { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +let LinkOndoPriceRule: new () => Migration; + +describe('LinkOndoPriceRule migration (postgres semantics)', () => { + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + LinkOndoPriceRule = require('../../../../../migration/1784039000000-LinkOndoPriceRule'); + }); + + beforeEach(async () => { + const db = newDb(); + db.public.registerFunction({ name: 'version', returns: DataType.text, implementation: () => 'PostgreSQL 15.0' }); + db.public.registerFunction({ name: 'current_database', returns: DataType.text, implementation: () => 'test' }); + + dataSource = (await db.adapters.createTypeormDataSource({ type: 'postgres', entities: [] })) as DataSource; + await dataSource.initialize(); + await dataSource.query(` + CREATE TABLE "asset" ( + "id" serial PRIMARY KEY, + "updated" timestamp NOT NULL DEFAULT NOW(), + "uniqueName" text NOT NULL, + "priceRuleId" integer + ) + `); + await dataSource.query(` + CREATE TABLE "price_rule" ( + "id" serial PRIMARY KEY, + "priceSource" text NOT NULL, + "priceAsset" text NOT NULL, + "priceReference" text NOT NULL + ) + `); + await dataSource.query(` + CREATE TABLE "migration_audit_lock" ( + "migration" text PRIMARY KEY + ) + `); + await dataSource.query(` + CREATE TABLE "migration_audit_event" ( + "id" serial PRIMARY KEY, + "migration" text NOT NULL, + "eventType" text NOT NULL, + "applyEventId" integer UNIQUE, + "payload" jsonb NOT NULL + ) + `); + await dataSource.query(` + CREATE TABLE "log" ( + "id" serial PRIMARY KEY, + "system" text NOT NULL, + "subsystem" text NOT NULL, + "message" text NOT NULL + ) + `); + await dataSource.query(`INSERT INTO "asset" ("id", "uniqueName") VALUES (398, 'Ethereum/ONDO')`); + await dataSource.query( + `INSERT INTO "price_rule" ("id", "priceSource", "priceAsset", "priceReference") + VALUES (60, 'CoinGecko', 'ondo-finance', 'tether')`, + ); + + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + }); + + afterEach(async () => { + await queryRunner.release(); + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + async function getPriceRuleId(): Promise { + return queryRunner + .query(`SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Ethereum/ONDO'`) + .then((rows) => rows.at(0).priceRuleId); + } + + async function getAuditEvents(): Promise[]> { + return queryRunner + .query(`SELECT "payload" FROM "migration_audit_event" ORDER BY "id"`) + .then((rows) => rows.map((row) => row.payload)); + } + + it('links the active ONDO asset to its semantic price rule and writes an audit event', async () => { + await new LinkOndoPriceRule().up(queryRunner); + + expect(await getPriceRuleId()).toBe(60); + expect(await getAuditEvents()).toEqual([ + { + action: 'applyOndoPriceRule', + assetId: 398, + uniqueName: 'Ethereum/ONDO', + previousPriceRuleId: null, + nextPriceRuleId: 60, + }, + ]); + }); + + it('resolves an environment-local rule id instead of assuming seed id 60', async () => { + await queryRunner.query(`DELETE FROM "price_rule"`); + await queryRunner.query( + `INSERT INTO "price_rule" ("id", "priceSource", "priceAsset", "priceReference") + VALUES (712, 'CoinGecko', 'ondo-finance', 'tether')`, + ); + + await new LinkOndoPriceRule().up(queryRunner); + + expect(await getPriceRuleId()).toBe(712); + }); + + it('is a no-op when ONDO already references the semantic rule', async () => { + await queryRunner.query(`UPDATE "asset" SET "priceRuleId" = 60 WHERE "id" = 398`); + + await new LinkOndoPriceRule().up(queryRunner); + + expect(await getPriceRuleId()).toBe(60); + expect(await getAuditEvents()).toEqual([]); + }); + + it('fails closed when the required ONDO asset is missing', async () => { + await queryRunner.query(`DELETE FROM "asset" WHERE "id" = 398`); + + await expect(new LinkOndoPriceRule().up(queryRunner)).rejects.toThrow('was not found'); + expect(await getAuditEvents()).toEqual([]); + }); + + it('fails closed on a missing, ambiguous, or conflicting price rule', async () => { + await queryRunner.query(`DELETE FROM "price_rule"`); + await expect(new LinkOndoPriceRule().up(queryRunner)).rejects.toThrow('exactly one ONDO price rule'); + + await queryRunner.query( + `INSERT INTO "price_rule" ("id", "priceSource", "priceAsset", "priceReference") VALUES + (60, 'CoinGecko', 'ondo-finance', 'tether'), + (61, 'CoinGecko', 'ondo-finance', 'tether')`, + ); + await expect(new LinkOndoPriceRule().up(queryRunner)).rejects.toThrow('found 2'); + + await queryRunner.query(`DELETE FROM "price_rule" WHERE "id" = 61`); + await queryRunner.query(`UPDATE "asset" SET "priceRuleId" = 999 WHERE "id" = 398`); + await expect(new LinkOndoPriceRule().up(queryRunner)).rejects.toThrow('refusing overwrite'); + + expect(await getPriceRuleId()).toBe(999); + expect(await getAuditEvents()).toEqual([]); + }); + + it('reverts only the price-rule assignment it owns and retains both audit events', async () => { + const migration = new LinkOndoPriceRule(); + await migration.up(queryRunner); + + await migration.down(queryRunner); + + expect(await getPriceRuleId()).toBeNull(); + expect((await getAuditEvents()).map((event) => event.action)).toEqual([ + 'applyOndoPriceRule', + 'rollbackOndoPriceRule', + ]); + expect((await getAuditEvents()).at(1)).toMatchObject({ + previousPriceRuleId: 60, + nextPriceRuleId: null, + outcome: 'reverted', + }); + }); + + it('preserves a later re-point and records the skipped rollback', async () => { + const migration = new LinkOndoPriceRule(); + await migration.up(queryRunner); + await queryRunner.query(`UPDATE "asset" SET "priceRuleId" = 999 WHERE "id" = 398`); + + await migration.down(queryRunner); + + expect(await getPriceRuleId()).toBe(999); + expect((await getAuditEvents()).at(1)).toMatchObject({ + previousPriceRuleId: 999, + nextPriceRuleId: 999, + outcome: 'skippedPriceRuleChanged', + }); + }); + + it('never mutates another asset that reused the audited ONDO id', async () => { + const migration = new LinkOndoPriceRule(); + await migration.up(queryRunner); + await queryRunner.query(`DELETE FROM "asset" WHERE "id" = 398`); + await queryRunner.query( + `INSERT INTO "asset" ("id", "uniqueName", "priceRuleId") VALUES (398, 'Ethereum/OTHER', 60)`, + ); + + await migration.down(queryRunner); + + const other = (await queryRunner.query(`SELECT "priceRuleId" FROM "asset" WHERE "id" = 398`)).at(0); + expect(other.priceRuleId).toBe(60); + expect((await getAuditEvents()).at(1)).toMatchObject({ + currentUniqueName: 'Ethereum/OTHER', + expectedUniqueName: 'Ethereum/ONDO', + outcome: 'skippedAssetIdentityChanged', + previousPriceRuleId: 60, + nextPriceRuleId: 60, + }); + }); + + it('supports two apply/rollback cycles with four distinct events', async () => { + const migration = new LinkOndoPriceRule(); + + await migration.up(queryRunner); + await migration.down(queryRunner); + await migration.up(queryRunner); + await migration.down(queryRunner); + + expect((await getAuditEvents()).map((event) => event.action)).toEqual([ + 'applyOndoPriceRule', + 'rollbackOndoPriceRule', + 'applyOndoPriceRule', + 'rollbackOndoPriceRule', + ]); + }); + + it('never treats a forged runtime log message as rollback authority', async () => { + await queryRunner.query(`UPDATE "asset" SET "priceRuleId" = 60 WHERE "id" = 398`); + await queryRunner.query( + `INSERT INTO "log" ("system", "subsystem", "message") VALUES ('Migration', 'LinkOndoPriceRule1784039000000', $1)`, + [ + JSON.stringify({ + action: 'applyOndoPriceRule', + assetId: 398, + uniqueName: 'Ethereum/ONDO', + previousPriceRuleId: null, + nextPriceRuleId: 60, + }), + ], + ); + + await new LinkOndoPriceRule().down(queryRunner); + + expect(await getPriceRuleId()).toBe(60); + expect(await getAuditEvents()).toEqual([]); + }); +}); diff --git a/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts b/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts index dcf3ccd892..61d6a438df 100644 --- a/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts +++ b/src/shared/models/setting/tests/add-denario-permanent-ref.migration.spec.ts @@ -6,20 +6,34 @@ type Migration = { down(queryRunner: QueryRunner): Promise; }; -const BACKUP_KEY = 'ref-keys.backup.1784037000000'; +const APPLY_ACTION = 'applyDenarioReferralAlias'; +const ROLLBACK_ACTION = 'rollbackDenarioReferralAlias'; + +type AuditLog = { + id: number; + eventType: 'Apply' | 'Rollback'; + applyEventId: number | null; + payload: unknown; + message: string; +}; type MigrationHarness = { queryRunner: QueryRunner; query: jest.Mock; getSetting(key?: string): string | undefined; + getLogs(): AuditLog[]; }; function createHarness(refs: unknown[] = ['123-456'], initialSetting?: string): MigrationHarness { const settings = new Map(); + const logs: AuditLog[] = []; if (initialSetting !== undefined) settings.set('ref-keys', initialSetting); const query = jest.fn(async (sql: string, parameters: unknown[] = []) => { if (sql.includes('FROM "user" u')) return refs.map((ref) => ({ ref })); + if (sql.startsWith('INSERT INTO "migration_audit_lock"')) return []; + if (sql.startsWith('SELECT "migration" FROM "migration_audit_lock"')) return [{ migration: parameters[0] }]; + if (sql.startsWith('SELECT "id", "eventType", "applyEventId", "payload"')) return logs; if (sql.startsWith('SELECT "value" FROM "setting"')) { const key = parameters[0] as string; return settings.has(key) ? [{ value: settings.get(key) }] : []; @@ -28,6 +42,17 @@ function createHarness(refs: unknown[] = ['123-456'], initialSetting?: string): settings.set(parameters[1] as string, parameters[0] as string); return []; } + if (sql.startsWith('INSERT INTO "migration_audit_event"')) { + const message = parameters[3] as string; + logs.push({ + id: logs.length + 1, + eventType: parameters[1] as 'Apply' | 'Rollback', + applyEventId: (parameters[2] as number | null) ?? null, + payload: JSON.parse(message), + message, + }); + return []; + } if (sql.startsWith('INSERT INTO "setting"')) { const key = parameters[0] as string; if (settings.has(key)) throw new Error(`duplicate key value violates unique constraint "${key}"`); @@ -46,6 +71,7 @@ function createHarness(refs: unknown[] = ['123-456'], initialSetting?: string): queryRunner: { query } as unknown as QueryRunner, query, getSetting: (key = 'ref-keys') => settings.get(key), + getLogs: () => logs, }; } @@ -73,52 +99,88 @@ describe('AddDenarioPermanentRef migration', () => { ]); }); - it('writes an immutable before-image backup before overwriting ref-keys', async () => { + it('writes an append-only before -> after audit event before overwriting ref-keys', async () => { const previous = JSON.stringify({ cakewallet: '111-222' }); const harness = createHarness(['123-456'], previous); await new AddDenarioPermanentRef().up(harness.queryRunner); - expect(JSON.parse(harness.getSetting(BACKUP_KEY)!)).toEqual({ existed: true, previous, ownedRef: '123-456' }); - // backup is inserted before the ref-keys update - const insertBackupCall = harness.query.mock.calls.findIndex( - ([sql, params]) => /^INSERT INTO "setting"/.test(sql) && params?.[0] === BACKUP_KEY, + expect(harness.getLogs().map((log) => JSON.parse(log.message))).toEqual([ + { + action: APPLY_ACTION, + settingKey: 'ref-keys', + existed: true, + previous, + next: JSON.stringify({ cakewallet: '111-222', denario: '123-456' }), + ownedRef: '123-456', + }, + ]); + const insertAuditCall = harness.query.mock.calls.findIndex(([sql]) => + /^INSERT INTO "migration_audit_event"/.test(sql), ); const updateRefKeysCall = harness.query.mock.calls.findIndex(([sql]) => /^UPDATE "setting"/.test(sql)); - expect(insertBackupCall).toBeGreaterThanOrEqual(0); - expect(insertBackupCall).toBeLessThan(updateRefKeysCall); + expect(insertAuditCall).toBeGreaterThanOrEqual(0); + expect(insertAuditCall).toBeLessThan(updateRefKeysCall); }); - it('creates ref-keys when the setting does not exist and records existed=false', async () => { + it('creates ref-keys when the setting does not exist and audits existed=false', async () => { const harness = createHarness(); await new AddDenarioPermanentRef().up(harness.queryRunner); expect(JSON.parse(harness.getSetting()!)).toEqual({ denario: '123-456' }); - expect(JSON.parse(harness.getSetting(BACKUP_KEY)!)).toEqual({ + expect(JSON.parse(harness.getLogs().at(0)!.message)).toEqual({ + action: APPLY_ACTION, + settingKey: 'ref-keys', existed: false, previous: null, + next: JSON.stringify({ denario: '123-456' }), ownedRef: '123-456', }); }); - it('is idempotent and writes no backup when the alias already targets the same account', async () => { + it('is idempotent and writes no audit event when the alias already targets the same account', async () => { const initialSetting = JSON.stringify({ denario: '123-456' }); const harness = createHarness(['123-456'], initialSetting); await new AddDenarioPermanentRef().up(harness.queryRunner); expect(harness.getSetting()).toBe(initialSetting); - expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); - expect(harness.query).toHaveBeenCalledTimes(2); + expect(harness.getLogs()).toEqual([]); + }); + + it('treats a direct reapply as a no-op while its active ownership still matches', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); + const migration = new AddDenarioPermanentRef(); + + await migration.up(harness.queryRunner); + await migration.up(harness.queryRunner); + + expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222', denario: '123-456' }); + expect(harness.getLogs().map((event) => JSON.parse(event.message).action)).toEqual([APPLY_ACTION]); + }); + + it('fails closed instead of creating a second apply when owned state changed before reapply', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); + const migration = new AddDenarioPermanentRef(); + await migration.up(harness.queryRunner); + await harness.queryRunner.query(`UPDATE "setting" SET "value" = $1, "updated" = NOW() WHERE "key" = $2`, [ + JSON.stringify({ cakewallet: '111-222' }), + 'ref-keys', + ]); + + await expect(migration.up(harness.queryRunner)).rejects.toThrow('ownership event no longer matches'); + + expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222' }); + expect(harness.getLogs().map((event) => JSON.parse(event.message).action)).toEqual([APPLY_ACTION]); }); - it('refuses to overwrite a conflicting Denario alias and writes no backup', async () => { + it('refuses to overwrite a conflicting Denario alias and writes no audit event', async () => { const harness = createHarness(['123-456'], JSON.stringify({ denario: '999-999' })); await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow('conflicting'); expect(JSON.parse(harness.getSetting()!)).toEqual({ denario: '999-999' }); - expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); + expect(harness.getLogs()).toEqual([]); }); it.each([ @@ -130,7 +192,7 @@ describe('AddDenarioPermanentRef migration', () => { await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow(error); expect(harness.getSetting()).toBeUndefined(); - expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); + expect(harness.getLogs()).toEqual([]); }); it.each(['not-json', '[]', 'null'])('rejects corrupt ref-keys configuration: %s', async (value) => { @@ -138,17 +200,26 @@ describe('AddDenarioPermanentRef migration', () => { await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow("Setting 'ref-keys'"); expect(harness.getSetting()).toBe(value); - expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); + expect(harness.getLogs()).toEqual([]); }); - it('removes only the Denario alias on rollback and clears the backup', async () => { + it('removes only the Denario alias and retains apply plus rollback audit events', async () => { const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); await new AddDenarioPermanentRef().up(harness.queryRunner); await new AddDenarioPermanentRef().down(harness.queryRunner); expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222' }); - expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); + const events = harness.getLogs().map((log) => JSON.parse(log.message)); + expect(events).toHaveLength(2); + expect(events.at(0)).toMatchObject({ action: APPLY_ACTION }); + expect(events.at(1)).toMatchObject({ + action: ROLLBACK_ACTION, + applyLogId: 1, + previous: JSON.stringify({ cakewallet: '111-222', denario: '123-456' }), + next: JSON.stringify({ cakewallet: '111-222' }), + outcome: 'reverted', + }); }); it('removes an otherwise empty ref-keys setting on rollback when it did not exist before', async () => { @@ -158,10 +229,11 @@ describe('AddDenarioPermanentRef migration', () => { await new AddDenarioPermanentRef().down(harness.queryRunner); expect(harness.getSetting()).toBeUndefined(); - expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); + expect(harness.getLogs()).toHaveLength(2); + expect(JSON.parse(harness.getLogs().at(1)!.message)).toMatchObject({ next: null, outcome: 'reverted' }); }); - it('is a no-op on rollback when up() never owned a change (no backup)', async () => { + it('is a no-op on rollback when up() never owned a change', async () => { const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); await new AddDenarioPermanentRef().down(harness.queryRunner); @@ -169,6 +241,90 @@ describe('AddDenarioPermanentRef migration', () => { expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222' }); }); + it('records a skipped rollback when the setting disappeared after apply', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); + await new AddDenarioPermanentRef().up(harness.queryRunner); + await harness.queryRunner.query(`DELETE FROM "setting" WHERE "key" = $1`, ['ref-keys']); + + await new AddDenarioPermanentRef().down(harness.queryRunner); + + expect(harness.getSetting()).toBeUndefined(); + expect(JSON.parse(harness.getLogs().at(1)!.message)).toMatchObject({ + outcome: 'skippedSettingMissing', + previous: null, + next: null, + }); + }); + + it('does not mutate ref-keys when the apply audit insert fails', async () => { + const initial = JSON.stringify({ cakewallet: '111-222' }); + const harness = createHarness(['123-456'], initial); + const implementation = harness.query.getMockImplementation()!; + harness.query.mockImplementation(async (sql: string, parameters: unknown[]) => { + if (sql.startsWith('INSERT INTO "migration_audit_event"')) throw new Error('audit unavailable'); + return implementation(sql, parameters); + }); + + await expect(new AddDenarioPermanentRef().up(harness.queryRunner)).rejects.toThrow('audit unavailable'); + + expect(harness.getSetting()).toBe(initial); + expect(harness.getLogs()).toEqual([]); + }); + + it('fails closed on corrupt or ambiguous audit history', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); + harness.getLogs().push({ id: 1, eventType: 'Apply', applyEventId: null, payload: 'not-json', message: 'not-json' }); + await expect(new AddDenarioPermanentRef().down(harness.queryRunner)).rejects.toThrow('Corrupt audit event'); + + harness.getLogs().splice( + 0, + 1, + { + id: 1, + eventType: 'Apply', + applyEventId: null, + payload: { + action: APPLY_ACTION, + settingKey: 'ref-keys', + existed: true, + previous: harness.getSetting(), + next: JSON.stringify({ cakewallet: '111-222', denario: '123-456' }), + ownedRef: '123-456', + }, + message: JSON.stringify({ + action: APPLY_ACTION, + settingKey: 'ref-keys', + existed: true, + previous: harness.getSetting(), + next: JSON.stringify({ cakewallet: '111-222', denario: '123-456' }), + ownedRef: '123-456', + }), + }, + { + id: 2, + eventType: 'Apply', + applyEventId: null, + payload: { + action: APPLY_ACTION, + settingKey: 'ref-keys', + existed: true, + previous: harness.getSetting(), + next: JSON.stringify({ cakewallet: '111-222', denario: '123-456' }), + ownedRef: '123-456', + }, + message: JSON.stringify({ + action: APPLY_ACTION, + settingKey: 'ref-keys', + existed: true, + previous: harness.getSetting(), + next: JSON.stringify({ cakewallet: '111-222', denario: '123-456' }), + ownedRef: '123-456', + }), + }, + ); + await expect(new AddDenarioPermanentRef().down(harness.queryRunner)).rejects.toThrow('Ambiguous audit state'); + }); + it('does not destroy a Denario alias re-pointed after deployment', async () => { const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); await new AddDenarioPermanentRef().up(harness.queryRunner); @@ -185,7 +341,29 @@ describe('AddDenarioPermanentRef migration', () => { await new AddDenarioPermanentRef().down(harness.queryRunner); expect(JSON.parse(harness.getSetting()!)).toEqual({ cakewallet: '111-222', denario: '555-666' }); - expect(harness.getSetting(BACKUP_KEY)).toBeUndefined(); + expect(JSON.parse(harness.getLogs().at(1)!.message)).toMatchObject({ + action: ROLLBACK_ACTION, + outcome: 'skippedAliasRepointed', + previous: JSON.stringify({ cakewallet: '111-222', denario: '555-666' }), + next: JSON.stringify({ cakewallet: '111-222', denario: '555-666' }), + }); + }); + + it('retains four distinct events across two complete apply/rollback cycles', async () => { + const harness = createHarness(['123-456'], JSON.stringify({ cakewallet: '111-222' })); + const migration = new AddDenarioPermanentRef(); + + await migration.up(harness.queryRunner); + await migration.down(harness.queryRunner); + await migration.up(harness.queryRunner); + await migration.down(harness.queryRunner); + + expect(harness.getLogs().map((log) => JSON.parse(log.message).action)).toEqual([ + APPLY_ACTION, + ROLLBACK_ACTION, + APPLY_ACTION, + ROLLBACK_ACTION, + ]); }); }); @@ -216,6 +394,8 @@ describe('AddDenarioPermanentRef migration (postgres semantics)', () => { beforeEach(async () => { await dataSource.query(`DROP TABLE IF EXISTS "setting"`); + await dataSource.query(`DROP TABLE IF EXISTS "migration_audit_event"`); + await dataSource.query(`DROP TABLE IF EXISTS "migration_audit_lock"`); await dataSource.query(`DROP TABLE IF EXISTS "user"`); await dataSource.query(`DROP TABLE IF EXISTS "user_data"`); await dataSource.query(` @@ -242,6 +422,20 @@ describe('AddDenarioPermanentRef migration (postgres semantics)', () => { "updated" timestamp NOT NULL DEFAULT NOW() ) `); + await dataSource.query(` + CREATE TABLE "migration_audit_lock" ( + "migration" text PRIMARY KEY + ) + `); + await dataSource.query(` + CREATE TABLE "migration_audit_event" ( + "id" serial PRIMARY KEY, + "migration" text NOT NULL, + "eventType" text NOT NULL, + "applyEventId" integer UNIQUE, + "payload" jsonb NOT NULL + ) + `); }); it('resolves the active organization ref and applies a reversible, auditable setting update', async () => { @@ -271,18 +465,22 @@ describe('AddDenarioPermanentRef migration (postgres semantics)', () => { await migration.up(queryRunner); const afterUp = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = 'ref-keys'`); expect(JSON.parse(afterUp.at(0).value)).toEqual({ cakewallet: '111-222', denario: '123-456' }); - const backup = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = '${BACKUP_KEY}'`); - expect(JSON.parse(backup.at(0).value)).toEqual({ - existed: true, + const applyLogs = await queryRunner.query(`SELECT "payload" FROM "migration_audit_event" ORDER BY "id"`); + expect(applyLogs.at(0).payload).toMatchObject({ + action: APPLY_ACTION, previous: JSON.stringify({ cakewallet: '111-222' }), - ownedRef: '123-456', + next: JSON.stringify({ cakewallet: '111-222', denario: '123-456' }), }); await migration.down(queryRunner); const afterDown = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = 'ref-keys'`); expect(JSON.parse(afterDown.at(0).value)).toEqual({ cakewallet: '111-222' }); - const backupAfterDown = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = '${BACKUP_KEY}'`); - expect(backupAfterDown).toHaveLength(0); + const logsAfterDown = await queryRunner.query(`SELECT "payload" FROM "migration_audit_event" ORDER BY "id"`); + expect(logsAfterDown).toHaveLength(2); + expect(logsAfterDown.at(1).payload).toMatchObject({ + action: ROLLBACK_ACTION, + outcome: 'reverted', + }); } finally { await queryRunner.release(); } diff --git a/src/subdomains/generic/user/models/wallet/wallet.entity.ts b/src/subdomains/generic/user/models/wallet/wallet.entity.ts index e939dfbcce..2a32a6cf1e 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.entity.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.entity.ts @@ -30,6 +30,7 @@ export class Wallet extends IEntity { address?: string; @Column({ length: 256, nullable: true }) + @Index({ unique: true, where: `"name" = 'Denario'` }) name?: string; @Column({ length: 256, nullable: true }) diff --git a/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts b/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts index 926aad77c2..ec6820968d 100644 --- a/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts +++ b/src/subdomains/supporting/payin/entities/__tests__/crypto-input.entity.spec.ts @@ -1,7 +1,28 @@ +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; import { createCustomCryptoInput } from '../__mocks__/crypto-input.entity.mock'; -import { PayInStatus } from '../crypto-input.entity'; +import { CryptoInput, PayInStatus, PayInType } from '../crypto-input.entity'; describe('CryptoInput', () => { + describe('#create(...)', () => { + it('fails an input whose register strategy could not resolve a priced asset', () => { + const input = CryptoInput.create( + 'sender', + BlockchainAddress.create('receiver', Blockchain.ETHEREUM), + 'tx-id', + PayInType.DEPOSIT, + null, + 1, + 1, + null as unknown as Asset, + ); + + expect(input.asset).toBeNull(); + expect(input.status).toBe(PayInStatus.FAILED); + }); + }); + describe('#fail(...)', () => { it('sets status to PayInStatus.FAILED', () => { const entity = createCustomCryptoInput({ id: 1, status: PayInStatus.ACKNOWLEDGED }); diff --git a/src/subdomains/supporting/payin/services/payin.service.ts b/src/subdomains/supporting/payin/services/payin.service.ts index be75df3051..25d2f37537 100644 --- a/src/subdomains/supporting/payin/services/payin.service.ts +++ b/src/subdomains/supporting/payin/services/payin.service.ts @@ -261,7 +261,7 @@ export class PayInService { } @DfxCron(CronExpression.EVERY_10_MINUTES, { process: Process.PAY_IN, timeout: 7200 }) - async updateFailedPayments() { + async updateFailedPayments(): Promise { const checkDate = Util.minutesBefore(15); const recentlyFailedPayments = await this.payInRepository.find({ @@ -269,10 +269,17 @@ export class PayInService { created: MoreThan(checkDate), txType: PayInType.PAYMENT, status: PayInStatus.FAILED, + asset: { priceRule: Not(IsNull()) }, }, + relations: { asset: { priceRule: true } }, }); for (const failedPayment of recentlyFailedPayments) { + // Unknown or unpriced tokens are intentionally persisted as FAILED by the register flow. They must + // never be resurrected by the payment-quote retry, otherwise the minute job processes an input that + // cannot be priced. Keep this guard in addition to the SQL filter as a fail-closed service boundary. + if (!failedPayment.asset?.priceRule) continue; + try { const quote = await this.paymentLinkPaymentService.getPaymentQuoteByFailedCryptoInput(failedPayment); diff --git a/src/subdomains/supporting/payin/strategies/register/impl/base/__tests__/alchemy.strategy.spec.ts b/src/subdomains/supporting/payin/strategies/register/impl/base/__tests__/alchemy.strategy.spec.ts new file mode 100644 index 0000000000..6a8b79d0dc --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/register/impl/base/__tests__/alchemy.strategy.spec.ts @@ -0,0 +1,216 @@ +import { createMock } from '@golevelup/ts-jest'; +import { AlchemyWebhookDto } from 'src/integration/alchemy/dto/alchemy-webhook.dto'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetRepository } from 'src/shared/models/asset/asset.repository'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PaymentLinkPayment } from 'src/subdomains/core/payment-link/entities/payment-link-payment.entity'; +import { PaymentQuote } from 'src/subdomains/core/payment-link/entities/payment-quote.entity'; +import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { CryptoInput, PayInStatus, PayInType } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayInRepository } from 'src/subdomains/supporting/payin/repositories/payin.repository'; +import { PayInBitcoinService } from 'src/subdomains/supporting/payin/services/payin-bitcoin.service'; +import { PayInFiroService } from 'src/subdomains/supporting/payin/services/payin-firo.service'; +import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; +import { SendStrategyRegistry } from 'src/subdomains/supporting/payin/strategies/send/impl/base/send.strategy-registry'; +import { IsNull, Not } from 'typeorm'; +import { RegisterStrategyRegistry } from '../register.strategy-registry'; +import { AlchemyStrategy } from '../alchemy.strategy'; + +const RECEIVER_ADDRESS = '0x1111111111111111111111111111111111111111'; +const SENDER_ADDRESS = '0x2222222222222222222222222222222222222222'; + +class TestAlchemyStrategy extends AlchemyStrategy { + protected readonly logger = new DfxLogger(TestAlchemyStrategy); + private txType = PayInType.DEPOSIT; + + get blockchain(): Blockchain { + return Blockchain.ETHEREUM; + } + + configure(assetService: AssetService, payInService: PayInService): void { + Reflect.set(this, 'assetService', assetService); + Reflect.set(this, 'payInService', payInService); + } + + setTxType(txType: PayInType): void { + this.txType = txType; + } + + process(dto: AlchemyWebhookDto): Promise { + return this.processWebhookTransactions(dto); + } + + protected getOwnAddresses(): string[] { + return []; + } + + protected async getPayInAddresses(): Promise { + return [RECEIVER_ADDRESS]; + } + + protected getTxType(): PayInType { + return this.txType; + } +} + +function createWebhook(chainId: string, decimals: number): AlchemyWebhookDto { + return { + webhookId: 'webhook-1', + id: `event-${chainId}`, + createdAt: '2026-07-15T00:00:00.000Z', + type: 'ADDRESS_ACTIVITY', + event: { + network: 'ETH_MAINNET', + activity: [ + { + fromAddress: SENDER_ADDRESS, + toAddress: RECEIVER_ADDRESS, + blockNum: '0x10', + hash: `0x${'a'.repeat(64)}`, + value: 1, + asset: 'TOKEN', + category: 'erc20', + rawContract: { + rawValue: `0x${(10n ** BigInt(decimals)).toString(16)}`, + decimals, + address: chainId, + }, + }, + ], + }, + }; +} + +describe('AlchemyStrategy priced pay-in boundary', () => { + let storedPayIns: CryptoInput[]; + let assetService: AssetService; + let payInService: PayInService; + let payInRepository: PayInRepository; + let paymentLinkPaymentService: PaymentLinkPaymentService; + let strategy: TestAlchemyStrategy; + + beforeEach(() => { + storedPayIns = []; + + const assetRepository = createMock(); + assetService = new AssetService(assetRepository); + + payInRepository = createMock(); + jest.spyOn(payInRepository, 'exists').mockResolvedValue(false); + jest.spyOn(payInRepository, 'save').mockImplementation(async (payIn: CryptoInput) => { + if (!storedPayIns.includes(payIn)) storedPayIns.push(payIn); + return payIn; + }); + jest + .spyOn(payInRepository, 'find') + .mockImplementation(async () => storedPayIns.filter((payIn) => payIn.status === PayInStatus.CREATED)); + + const transactionService = createMock(); + jest + .spyOn(transactionService, 'create') + .mockResolvedValue({ id: storedPayIns.length + 1 } as unknown as Transaction); + + paymentLinkPaymentService = createMock(); + payInService = new PayInService( + payInRepository, + createMock(), + createMock(), + transactionService, + paymentLinkPaymentService, + createMock(), + createMock(), + ); + + strategy = new TestAlchemyStrategy(); + strategy.configure(assetService, payInService); + }); + + it('creates a processable ONDO input when the priced asset is present', async () => { + const ondo = Object.assign(new Asset(), { + id: 398, + name: 'ONDO', + uniqueName: 'Ethereum/ONDO', + blockchain: Blockchain.ETHEREUM, + type: AssetType.TOKEN, + chainId: '0xfaba6f8e4a5e8ab82f62fe7c39859fa577269be3', + decimals: 18, + priceRule: { id: 60 }, + }); + jest.spyOn(assetService, 'getPayInAssets').mockResolvedValue([ondo]); + + await strategy.process(createWebhook(ondo.chainId, ondo.decimals)); + + expect(storedPayIns).toHaveLength(1); + expect(storedPayIns.at(0)).toMatchObject({ asset: ondo, status: PayInStatus.CREATED }); + await expect(payInService.getNewPayIns()).resolves.toEqual(storedPayIns); + }); + + it('stores an inert unpriced DGC input as FAILED and never returns it for processing', async () => { + jest.spyOn(assetService, 'getPayInAssets').mockResolvedValue([]); + + await strategy.process(createWebhook('0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f', 8)); + + expect(storedPayIns).toHaveLength(1); + expect(storedPayIns.at(0)).toMatchObject({ asset: null, status: PayInStatus.FAILED }); + await expect(payInService.getNewPayIns()).resolves.toEqual([]); + }); + + it('never resurrects an unpriced payment through the failed-payment retry', async () => { + strategy.setTxType(PayInType.PAYMENT); + jest.spyOn(assetService, 'getPayInAssets').mockResolvedValue([]); + + await strategy.process(createWebhook('0xf7e2d612f1a0ce09ce9fc6fc0b59c7fd5b75042f', 8)); + + const find = jest.spyOn(payInRepository, 'find').mockResolvedValue(storedPayIns); + const retryQuote = jest.spyOn(paymentLinkPaymentService, 'getPaymentQuoteByFailedCryptoInput'); + + await payInService.updateFailedPayments(); + + expect(find).toHaveBeenCalledWith({ + where: expect.objectContaining({ asset: { priceRule: Not(IsNull()) } }), + relations: { asset: { priceRule: true } }, + }); + expect(retryQuote).not.toHaveBeenCalled(); + expect(storedPayIns.at(0)).toMatchObject({ asset: null, status: PayInStatus.FAILED }); + }); + + it('still retries a failed payment whose asset remains priced', async () => { + const ondo = Object.assign(new Asset(), { + id: 398, + name: 'ONDO', + uniqueName: 'Ethereum/ONDO', + blockchain: Blockchain.ETHEREUM, + type: AssetType.TOKEN, + chainId: '0xfaba6f8e4a5e8ab82f62fe7c39859fa577269be3', + decimals: 18, + priceRule: { id: 60 }, + }); + strategy.setTxType(PayInType.PAYMENT); + jest.spyOn(assetService, 'getPayInAssets').mockResolvedValue([ondo]); + jest.spyOn(paymentLinkPaymentService, 'getPaymentQuoteByCryptoInput').mockRejectedValue(new Error('quote pending')); + + await strategy.process(createWebhook(ondo.chainId, ondo.decimals)); + expect(storedPayIns.at(0)).toMatchObject({ asset: ondo, status: PayInStatus.FAILED }); + + jest.spyOn(payInRepository, 'find').mockResolvedValue([...storedPayIns]); + const payment = new PaymentLinkPayment(); + const quote = Object.assign(new PaymentQuote(), { payment }); + const retryQuote = jest + .spyOn(paymentLinkPaymentService, 'getPaymentQuoteByFailedCryptoInput') + .mockResolvedValue(quote); + + await payInService.updateFailedPayments(); + + const retriedPayIn = storedPayIns.at(0)!; + expect(retryQuote).toHaveBeenCalledTimes(1); + expect(retryQuote.mock.calls.at(0)?.at(0)).toBe(retriedPayIn); + expect(retriedPayIn.asset).toBe(ondo); + expect(retriedPayIn.paymentLinkPayment).toBe(payment); + expect(retriedPayIn.paymentQuote).toBe(quote); + expect(retriedPayIn.status).toBe(PayInStatus.CREATED); + }); +}); diff --git a/src/subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts index ea561bbcf7..e517af2e62 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/base/alchemy.strategy.ts @@ -54,7 +54,7 @@ export abstract class AlchemyStrategy extends EvmStrategy implements OnModuleIni }); } - private async processWebhookTransactions(dto: AlchemyWebhookDto): Promise { + protected async processWebhookTransactions(dto: AlchemyWebhookDto): Promise { const fromAddresses = this.getOwnAddresses(); const toAddresses = await this.getPayInAddresses();