From 274c4662d9d52928b1f4f6470884f1159031f299 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 28 Jul 2026 16:39:37 +0200 Subject: [PATCH 1/3] Add Unstoppable Wallet as an OpenCryptoPay payment-link wallet Register Unstoppable Wallet (Horizontal Systems) in the wallet_app catalog so it is offered on payment-link pages. Unstoppable ships native OpenCryptoPay support on iOS and Android; the eleven supported transfer methods were verified against the app sources (iOS OpenCryptoPayBroadcasterFactory.unstoppable, Android OcpTransferAmount.supportedBlockchainTypes). The six EVM chains are listed explicitly instead of the EvmBlockchains placeholder, because Unstoppable does not OCP-broadcast Gnosis, Haqq or Citrea. hasActionDeepLink stays NULL: OpenCryptoPayUrl.detect only accepts an https URL with a lightning query, so the deepLink+lightning: action link does not apply here. Idempotent SELECT-then-skip guard against the UNIQUE(name) constraint, matching the existing migration convention. --- .../1785400000000-AddUnstoppableWalletApp.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 migration/1785400000000-AddUnstoppableWalletApp.js diff --git a/migration/1785400000000-AddUnstoppableWalletApp.js b/migration/1785400000000-AddUnstoppableWalletApp.js new file mode 100644 index 0000000000..c5b9d8ab11 --- /dev/null +++ b/migration/1785400000000-AddUnstoppableWalletApp.js @@ -0,0 +1,54 @@ +// Add Unstoppable Wallet (Horizontal Systems) to the wallet_app table so it appears as a +// payment app on DFX OpenCryptoPay payment-link pages (app.dfx.swiss/pl). +// +// Unstoppable ships native OpenCryptoPay support on iOS and Android. It detects the standard +// OCP QR (https URL with lightning= LNURL query), decodes the LNURL and pays via its OCP +// broadcasters. Supported OCP transfer methods verified against the app sources on 2026-07-28: +// - iOS: OpenCryptoPayBroadcasterFactory.unstoppable registers EvmHex, Tron, Bitcoin, Solana, +// Zano and MoneroHash broadcasters; their supportedChains maps yield the method names below. +// - Android: OcpTransferAmount.supportedBlockchainTypes() in OpenCryptoPayRepository.kt lists +// the same eleven methods. +// Resulting blockchains: Ethereum, BinanceSmartChain, Polygon, Arbitrum, Optimism, Base, +// Bitcoin, Solana, Tron, Zano, Monero. +// +// EVM chains are listed explicitly instead of the EvmBlockchains placeholder: Unstoppable only +// OCP-broadcasts those six EVMs; Gnosis, Haqq, Citrea and CitreaTestnet from EvmBlockchains +// are not included. See WalletApp.supportedBlockchainList. +// +// Columns intentionally omitted so they stay at DB default / NULL: +// - recommended: normal wallet, not shown in the recommended block +// - semiCompatible: full native OCP support, not semi-compatible +// - assets: no asset restriction; arbitrary tokens on the listed chains +// - hasActionDeepLink: must stay NULL. That flag means a non-Lightning wallet accepts payment +// via deepLink + lightning: + LNURL (see SetRealUnitHasActionDeepLink migration). For +// Unstoppable that is wrong: OpenCryptoPayUrl.detect on iOS only accepts scheme https with a +// lightning query; a unstoppable.money:lightning:... URL fails with the cannot-recognize +// banner. Same behaviour as Cake Wallet flag NULL, not RealUnit. +// +// deepLink unstoppable.money: is a registered URL scheme on both platforms, so the frontend +// open-app action works. iconUrl already hosts on dfx.swiss and returns HTTP 200. + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +module.exports = class AddUnstoppableWalletApp1785400000000 { + name = 'AddUnstoppableWalletApp1785400000000'; + + async up(queryRunner) { + // Idempotent guard against UNIQUE(name): skip if Unstoppable Wallet already exists. + const existing = (await queryRunner.query(`SELECT "id" FROM "wallet_app" WHERE "name" = 'Unstoppable Wallet'`)).at( + 0, + ); + if (existing) return; + + await queryRunner.query( + `INSERT INTO "wallet_app" ("name", "websiteUrl", "iconUrl", "deepLink", "appStoreUrl", "playStoreUrl", "blockchains", "active") VALUES ('Unstoppable Wallet', 'https://unstoppable.money/', 'https://dfx.swiss/images/app/UnstoppableWallet.webp', 'unstoppable.money:', 'https://apps.apple.com/app/unstoppable-crypto-wallet/id1447619907', 'https://play.google.com/store/apps/details?id=io.horizontalsystems.bankwallet', 'Ethereum;BinanceSmartChain;Polygon;Arbitrum;Optimism;Base;Bitcoin;Solana;Tron;Zano;Monero', true)`, + ); + } + + async down(queryRunner) { + await queryRunner.query(`DELETE FROM "wallet_app" WHERE "name" = 'Unstoppable Wallet'`); + } +}; From 0427c95a73c96a864a55385b0ddd3b655127af4c Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 28 Jul 2026 18:53:41 +0200 Subject: [PATCH 2/3] Guard the Unstoppable wallet down() against foreign rows and add a migration spec up() skips an existing 'Unstoppable Wallet' row via its SELECT guard, so a name-only DELETE in down() would remove a row this migration never created - a hand-created or later edited row would be destroyed by an up/down cycle. down() now additionally matches the characteristic values up() writes (deepLink + blockchains), the same exact-value idiom the sibling migrations use. The new spec covers both layers, mirroring AddSavingZchfAsset: mocked queryRunner for the SQL content (all eleven methods in order, no hasActionDeepLink/recommended/semiCompatible/assets, no INSERT when the row exists) and a real-Postgres suite that creates a pre-existing foreign row before up() and asserts an up/down cycle leaves it intact. --- .../1785400000000-AddUnstoppableWalletApp.js | 11 +- ...d-unstoppable-wallet-app.migration.spec.ts | 224 ++++++++++++++++++ 2 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 src/subdomains/core/payment-link/__tests__/add-unstoppable-wallet-app.migration.spec.ts diff --git a/migration/1785400000000-AddUnstoppableWalletApp.js b/migration/1785400000000-AddUnstoppableWalletApp.js index c5b9d8ab11..951dd9cba2 100644 --- a/migration/1785400000000-AddUnstoppableWalletApp.js +++ b/migration/1785400000000-AddUnstoppableWalletApp.js @@ -27,6 +27,12 @@ // // deepLink unstoppable.money: is a registered URL scheme on both platforms, so the frontend // open-app action works. iconUrl already hosts on dfx.swiss and returns HTTP 200. +// +// down() ownership: DELETE matches name AND the characteristic values this migration wrote +// (deepLink + blockchains). A row that was hand-created or later edited with different +// values is not ours — up() would have skipped it via the SELECT guard — so a bare +// name-only DELETE would destroy foreign data on an up/down cycle. Same exact-value idiom as +// SetRealUnitHasActionDeepLink (and PopulateNativeCoinDecimals). /** * @typedef {import('typeorm').MigrationInterface} MigrationInterface @@ -49,6 +55,9 @@ module.exports = class AddUnstoppableWalletApp1785400000000 { } async down(queryRunner) { - await queryRunner.query(`DELETE FROM "wallet_app" WHERE "name" = 'Unstoppable Wallet'`); + // Only remove the row this migration created. See header comment on ownership. + await queryRunner.query( + `DELETE FROM "wallet_app" WHERE "name" = 'Unstoppable Wallet' AND "deepLink" = 'unstoppable.money:' AND "blockchains" = 'Ethereum;BinanceSmartChain;Polygon;Arbitrum;Optimism;Base;Bitcoin;Solana;Tron;Zano;Monero'`, + ); } }; diff --git a/src/subdomains/core/payment-link/__tests__/add-unstoppable-wallet-app.migration.spec.ts b/src/subdomains/core/payment-link/__tests__/add-unstoppable-wallet-app.migration.spec.ts new file mode 100644 index 0000000000..d576b31775 --- /dev/null +++ b/src/subdomains/core/payment-link/__tests__/add-unstoppable-wallet-app.migration.spec.ts @@ -0,0 +1,224 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'unstoppable_wallet_app_spec'; + +const EXPECTED_BLOCKCHAINS = + 'Ethereum;BinanceSmartChain;Polygon;Arbitrum;Optimism;Base;Bitcoin;Solana;Tron;Zano;Monero'; +const EXPECTED_DEEP_LINK = 'unstoppable.money:'; +const WALLET_NAME = 'Unstoppable Wallet'; + +let AddUnstoppableWalletApp: new () => { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +describe('AddUnstoppableWalletApp migration (SQL content)', () => { + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddUnstoppableWalletApp = require('../../../../../migration/1785400000000-AddUnstoppableWalletApp'); + }); + + it('inserts all eleven methods in order and omits hasActionDeepLink/recommended/semiCompatible/assets', async () => { + const migration = new AddUnstoppableWalletApp(); + const queryRunner = { + query: jest.fn(async (sql: string) => { + const s = sql.toLowerCase(); + if (s.includes('from "wallet_app"') && s.includes(`'unstoppable wallet'`) && !s.includes('insert')) { + return []; + } + return []; + }), + }; + + await migration.up(queryRunner as unknown as QueryRunner); + + const calls = queryRunner.query.mock.calls as [string, unknown[]?][]; + expect(calls).toHaveLength(2); + + const insertSql = calls.find(([statement]) => statement.toLowerCase().includes('insert'))?.[0] ?? ''; + expect(insertSql).toContain(EXPECTED_BLOCKCHAINS); + expect(insertSql).toContain(EXPECTED_DEEP_LINK); + expect(insertSql).toContain(`'${WALLET_NAME}'`); + + // Columns intentionally left at DB default / NULL — must not appear in the INSERT column list. + expect(insertSql.toLowerCase()).not.toContain('hasactiondeeplink'); + expect(insertSql.toLowerCase()).not.toContain('recommended'); + expect(insertSql.toLowerCase()).not.toContain('semicompatible'); + expect(insertSql.toLowerCase()).not.toContain('"assets"'); + }); + + it('is idempotent: only SELECT runs when Unstoppable Wallet already exists', async () => { + const migration = new AddUnstoppableWalletApp(); + const queryRunner = { + query: jest.fn(async (sql: string) => { + const s = sql.toLowerCase(); + if (s.includes('from "wallet_app"') && s.includes(`'unstoppable wallet'`) && !s.includes('insert')) { + return [{ id: 42 }]; + } + return []; + }), + }; + + await migration.up(queryRunner as unknown as QueryRunner); + + const calls = queryRunner.query.mock.calls as [string, unknown[]?][]; + expect(calls).toHaveLength(1); + expect(calls[0][0].toLowerCase()).toContain('select'); + expect(calls.some(([statement]) => statement.toLowerCase().includes('insert'))).toBe(false); + }); + + it('down() deletes only by name + deepLink + blockchains (ownership guard)', async () => { + const migration = new AddUnstoppableWalletApp(); + const queryRunner = { + query: jest.fn(async (_sql: string) => []), + }; + + await migration.down(queryRunner as unknown as QueryRunner); + + const calls = queryRunner.query.mock.calls as [string, unknown[]?][]; + expect(calls).toHaveLength(1); + const deleteSql = calls[0][0]; + expect(deleteSql.toLowerCase()).toContain('delete'); + expect(deleteSql).toContain(`'${WALLET_NAME}'`); + expect(deleteSql).toContain(EXPECTED_DEEP_LINK); + expect(deleteSql).toContain(EXPECTED_BLOCKCHAINS); + }); +}); + +describeDb('AddUnstoppableWalletApp migration (real Postgres)', () => { + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddUnstoppableWalletApp = require('../../../../../migration/1785400000000-AddUnstoppableWalletApp'); + dataSource = new DataSource({ type: 'postgres', url: PG_URL }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.query(`CREATE SCHEMA "${SCHEMA}"`); + await queryRunner.query(`SET search_path TO "${SCHEMA}"`); + + // Minimal fixture matching wallet-app.entity.ts / initial schema columns the migration + // touches. UNIQUE(name) is required: the idempotency guard exists to protect against it. + await queryRunner.query(` + CREATE TABLE "wallet_app" ( + "id" SERIAL PRIMARY KEY, + "updated" TIMESTAMP NOT NULL DEFAULT now(), + "created" TIMESTAMP NOT NULL DEFAULT now(), + "name" character varying(256) NOT NULL, + "websiteUrl" character varying(256), + "iconUrl" character varying(256) NOT NULL, + "deepLink" character varying(256), + "hasActionDeepLink" boolean, + "appStoreUrl" text, + "playStoreUrl" text, + "recommended" boolean, + "blockchains" text, + "assets" character varying(256), + "semiCompatible" boolean, + "active" boolean NOT NULL DEFAULT true, + CONSTRAINT "UQ_wallet_app_name_spec" UNIQUE ("name") + ) + `); + }); + + afterEach(async () => { + if (queryRunner.isTransactionActive) await queryRunner.rollbackTransaction(); + await queryRunner.query(`SET search_path TO public`); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.release(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + it('up() creates the row with exact blockchains and deepLink', async () => { + const migration = new AddUnstoppableWalletApp(); + await migration.up(queryRunner); + + const rows = await queryRunner.query( + `SELECT "name", "websiteUrl", "iconUrl", "deepLink", "appStoreUrl", "playStoreUrl", + "blockchains", "active", "hasActionDeepLink", "recommended", "semiCompatible", "assets" + FROM "wallet_app" WHERE "name" = '${WALLET_NAME}'`, + ); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: WALLET_NAME, + websiteUrl: 'https://unstoppable.money/', + iconUrl: 'https://dfx.swiss/images/app/UnstoppableWallet.webp', + deepLink: EXPECTED_DEEP_LINK, + appStoreUrl: 'https://apps.apple.com/app/unstoppable-crypto-wallet/id1447619907', + playStoreUrl: 'https://play.google.com/store/apps/details?id=io.horizontalsystems.bankwallet', + blockchains: EXPECTED_BLOCKCHAINS, + active: true, + }); + expect(rows[0].hasActionDeepLink).toBeNull(); + expect(rows[0].recommended).toBeNull(); + expect(rows[0].semiCompatible).toBeNull(); + expect(rows[0].assets).toBeNull(); + }); + + it('is idempotent: re-running up() does not create a second row', async () => { + const migration = new AddUnstoppableWalletApp(); + await migration.up(queryRunner); + await expect(migration.up(queryRunner)).resolves.not.toThrow(); + + const count = ( + await queryRunner.query(`SELECT COUNT(*)::int AS c FROM "wallet_app" WHERE "name" = '${WALLET_NAME}'`) + )[0].c; + + expect(count).toBe(1); + }); + + it('down() does not delete a pre-existing foreign Unstoppable Wallet row (ownership)', async () => { + // Hand-created row with the same name but different characteristic values — not ours. + await queryRunner.query(` + INSERT INTO "wallet_app" ("name", "iconUrl", "deepLink", "blockchains", "active") + VALUES ('${WALLET_NAME}', 'https://example.com/foreign.webp', 'foreign-scheme:', 'Bitcoin', true) + `); + + const before = await queryRunner.query( + `SELECT "name", "iconUrl", "deepLink", "blockchains", "active" FROM "wallet_app" WHERE "name" = '${WALLET_NAME}'`, + ); + expect(before).toHaveLength(1); + + const migration = new AddUnstoppableWalletApp(); + // up() sees the name and skips; down() must not wipe the foreign row. + await migration.up(queryRunner); + await migration.down(queryRunner); + + const after = await queryRunner.query( + `SELECT "name", "iconUrl", "deepLink", "blockchains", "active" FROM "wallet_app" WHERE "name" = '${WALLET_NAME}'`, + ); + + expect(after).toHaveLength(1); + expect(after[0]).toMatchObject({ + name: WALLET_NAME, + iconUrl: 'https://example.com/foreign.webp', + deepLink: 'foreign-scheme:', + blockchains: 'Bitcoin', + active: true, + }); + }); + + it('up() then down() on an empty table removes the row this migration created', async () => { + const migration = new AddUnstoppableWalletApp(); + await migration.up(queryRunner); + await migration.down(queryRunner); + + const count = ( + await queryRunner.query(`SELECT COUNT(*)::int AS c FROM "wallet_app" WHERE "name" = '${WALLET_NAME}'`) + )[0].c; + + expect(count).toBe(0); + }); +}); From d9861f148f08980f6495ab931534d59f4d860aa0 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 29 Jul 2026 12:32:44 +0200 Subject: [PATCH 3/3] Document MIGRATION_TEST_PG in .env.example --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 5e7045785c..395fee5372 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ SQL_POOL_MAX=10 SQL_POOL_IDLE_TIMEOUT=30000 SQL_LOGGING= +# Postgres connection string for the migration specs that run real SQL +# (src/**/__tests__/*.migration.spec.ts). Optional — those specs skip when unset. +MIGRATION_TEST_PG= + JWT_SECRET=xxx JWT_EXPIRES_IN=14d JWT_EXPIRES_IN_COMPANY=1d