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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions migration/1785400000000-AddUnstoppableWalletApp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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.
//
// 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
* @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) {
// 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'`,
);
}
};
Original file line number Diff line number Diff line change
@@ -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<void>;
down(queryRunner: QueryRunner): Promise<void>;
};

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);
});
});