diff --git a/.env.local.example b/.env.local.example
index 4ec79d55c2..f6fa50ea06 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -6,7 +6,7 @@
# 3. npm run setup
#
# That's it! The setup script will:
-# - Generate all 19 wallet seeds/keys
+# - Generate all required wallet seeds/keys
# - Start the API in the background
# - Wait for database tables and seed data
# - Register an admin user
@@ -31,13 +31,13 @@ REQUEST_LIMIT_CHECK=false
# Database (matches docker-compose.yml)
SQL_HOST=localhost
-SQL_PORT=1433
+SQL_PORT=5432
SQL_USERNAME=sa
SQL_PASSWORD=LocalDev2026@SQL
SQL_DB=dfx
SQL_SYNCHRONIZE=true
SQL_MIGRATE=false
-SQL_ENCRYPT=false
+SQL_SSL=false
# Auth
JWT_SECRET=local-dev-secret-change-in-production
diff --git a/.github/workflows/api-migration-check.yaml b/.github/workflows/api-migration-check.yaml
index ebff765bb3..506a50b4e0 100644
--- a/.github/workflows/api-migration-check.yaml
+++ b/.github/workflows/api-migration-check.yaml
@@ -7,6 +7,8 @@ on:
- develop
paths:
- 'migration/**'
+ # The seed script and its data are actively maintained, not immutable schema migrations.
+ - '!migration/seed/**'
permissions:
contents: read
@@ -24,7 +26,8 @@ jobs:
- name: Block changes to existing migrations
run: |
git fetch origin ${{ github.base_ref }} --depth=100
- CHANGED=$(git diff --name-status origin/${{ github.base_ref }}...HEAD -- migration/ \
+ # Protect immutable schema migrations only; the seed script/data may change freely.
+ CHANGED=$(git diff --name-status origin/${{ github.base_ref }}...HEAD -- migration/ ':(exclude)migration/seed/' \
| awk '$1 ~ /^[MDR]/ {print}')
if [ -n "$CHANGED" ]; then
echo "::error::Existing migrations must not be modified, renamed or deleted."
diff --git a/README.md b/README.md
index 133e5aa38b..4fc3365eb4 100644
--- a/README.md
+++ b/README.md
@@ -222,9 +222,10 @@ npm run start:local # Restart API manually
The `npm run setup` command is an all-in-one script that:
-1. **Generates All Wallet Seeds**: Creates 19 secure random seeds/keys and saves them to `.env`:
- - 10 mnemonic seeds (ADMIN, EVM_DEPOSIT, EVM_CUSTODY, SOLANA, TRON, CARDANO, PAYMENT_*)
- - 9 EVM private keys (shared across all EVM chains)
+1. **Generates All Wallet Seeds**: Creates secure random seeds/keys and saves them to `.env`:
+ - Mnemonic seeds (ADMIN, EVM_DEPOSIT, EVM_CUSTODY, SOLANA, TRON, CARDANO, ICP, SPARK, BOLTZ, PAYMENT_*)
+ - EVM private keys (shared across all EVM chains)
+ - Service-specific keys (Ark hex, KuCoin Pay PKCS#8, payment-webhook PEM)
2. **Starts API**: Launches the API in the background (logs to `api.log`, PID saved to `.api.pid`)
3. **Registers Admin User**: Uses the API auth endpoint to create and authenticate the admin
4. **Sets Admin Role**: Grants admin privileges in the database
@@ -256,10 +257,9 @@ Seed data is stored in `migration/seed/` and can be customized as needed.
```bash
docker compose up -d # Start database
-docker compose logs db-init # Check if database was created
docker compose down # Stop database
docker compose down -v # Stop and delete data
-docker logs dfx-mssql # View database logs
+docker logs dfx-postgres # View database logs
```
### Environment Configuration
@@ -269,7 +269,7 @@ The `.env.local.example` template contains minimal config for local development:
- `ENVIRONMENT=loc` - Enables mock mode for external services
- `DISABLED_PROCESSES=*` - Disables all background jobs
- `SQL_SYNCHRONIZE=true` - Auto-creates database tables from entities
-- `SQL_ENCRYPT=false` - Trusts Docker's self-signed SSL certificate
+- `SQL_SSL=false` - Disables SSL for the local Postgres connection
**Note:** The template does not contain wallet seeds. All seeds are generated securely by `npm run setup` and written to your local `.env` file.
@@ -283,7 +283,7 @@ When `ENVIRONMENT=loc`, external services are automatically mocked to simplify l
- **Mail service**: Mail sending is logged but not actually sent
**❌ What's NOT mocked:**
-- **Database**: Requires running MSSQL instance (via Docker)
+- **Database**: Requires running PostgreSQL instance (via Docker)
- **Blockchain services**: Still initialize with credentials from `.env`
- **Localhost calls**: Requests to localhost/127.0.0.1 are never mocked
diff --git a/docker-compose.yml b/docker-compose.yml
index cabcc86ae9..8bd43fee6f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,37 +1,21 @@
services:
db:
- image: mcr.microsoft.com/mssql/server:2022-latest
- container_name: dfx-mssql
+ image: postgres:16
+ container_name: dfx-postgres
environment:
- - ACCEPT_EULA=Y
- - MSSQL_SA_PASSWORD=LocalDev2026@SQL
- - MSSQL_PID=Developer
+ - POSTGRES_USER=sa
+ - POSTGRES_PASSWORD=LocalDev2026@SQL
+ - POSTGRES_DB=dfx
ports:
- - "1433:1433"
+ - "5432:5432"
volumes:
- - mssql_data:/var/opt/mssql
+ - postgres_data:/var/lib/postgresql/data
healthcheck:
- test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "LocalDev2026@SQL" -C -Q "SELECT 1" || exit 1
+ test: ["CMD-SHELL", "pg_isready -U sa -d dfx"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
- db-init:
- image: mcr.microsoft.com/mssql/server:2022-latest
- depends_on:
- db:
- condition: service_healthy
- entrypoint: ["/bin/bash", "-c"]
- command:
- - |
- /opt/mssql-tools18/bin/sqlcmd -S db -U sa -P "LocalDev2026@SQL" -C -Q "
- IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'dfx')
- BEGIN
- CREATE DATABASE dfx
- END
- "
- echo "Database 'dfx' ready"
-
volumes:
- mssql_data:
+ postgres_data:
diff --git a/eslint.config.js b/eslint.config.js
index c4c695fb0f..4b686a1ae0 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -23,6 +23,20 @@ module.exports = tseslint.config(
rules: {
'no-return-await': 'off',
'no-console': ['warn'],
+ // The 'Config' singleton is undefined until ConfigService is constructed. Reading it in a
+ // field initializer of a Nest-instantiated class (@Injectable/@Controller) crashes app
+ // bootstrap when that provider is instantiated before ConfigService (provider ordering /
+ // circular dependency). Scoped to those decorators so request DTOs/entities (built at
+ // runtime, when Config is already set) are not affected. Use a getter or onModuleInit.
+ 'no-restricted-syntax': [
+ 'error',
+ {
+ selector:
+ "ClassDeclaration:has(Decorator[expression.callee.name=/^(Injectable|Controller)$/]) PropertyDefinition[value] MemberExpression[object.name='Config']",
+ message:
+ "Do not read the 'Config' singleton in a field initializer of an @Injectable/@Controller class: Config is undefined until ConfigService is constructed, which crashes app bootstrap if this provider is instantiated first. Use a getter or read Config in onModuleInit instead.",
+ },
+ ],
'@typescript-eslint/return-await': ['warn', 'in-try-catch'],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
diff --git a/migration/seed/seed.js b/migration/seed/seed.js
index c1f23d363e..289103a134 100644
--- a/migration/seed/seed.js
+++ b/migration/seed/seed.js
@@ -1,6 +1,6 @@
const fs = require('fs');
const path = require('path');
-const mssql = require('mssql');
+const { Client } = require('pg');
// ============================================================================
// SAFETY CHECKS - Prevent accidental seeding of production databases
@@ -22,12 +22,12 @@ const dbHost = process.env.SQL_HOST || 'localhost';
const allowedHostPatterns = [
'localhost',
'127.0.0.1',
- /^sql-dfx-api-loc/i, // Azure loc database
+ /^sql-dfx-api-loc/i, // Azure loc database
/loc.*\.database\.windows\.net/i, // Any Azure loc database
];
-const isHostAllowed = allowedHostPatterns.some(pattern =>
- pattern instanceof RegExp ? pattern.test(dbHost) : dbHost === pattern
+const isHostAllowed = allowedHostPatterns.some((pattern) =>
+ pattern instanceof RegExp ? pattern.test(dbHost) : dbHost === pattern,
);
if (!isHostAllowed) {
@@ -44,18 +44,18 @@ console.log(`✓ Safety checks passed (env=${currentEnv || 'unset'}, host=${dbHo
const config = {
user: process.env.SQL_USERNAME || 'sa',
password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL',
- server: process.env.SQL_HOST || 'localhost',
- port: parseInt(process.env.SQL_PORT) || 1433,
+ host: process.env.SQL_HOST || 'localhost',
+ port: parseInt(process.env.SQL_PORT) || 5432,
database: process.env.SQL_DB || 'dfx',
- options: {
- encrypt: process.env.SQL_ENCRYPT === 'true',
- trustServerCertificate: true
- }
+ // Local-only script (host is restricted above); default to no SSL, opt in via SQL_SSL=true.
+ ssl: process.env.SQL_SSL === 'true' ? { rejectUnauthorized: false } : false,
};
+const quoteIdent = (name) => `"${name}"`;
+
function parseCSV(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
- const lines = content.split('\n').filter(l => l.trim());
+ const lines = content.split('\n').filter((l) => l.trim());
if (lines.length === 0) return [];
const parseRow = (line) => {
@@ -77,7 +77,7 @@ function parseCSV(filePath) {
return result;
};
- const headers = parseRow(lines[0]).filter(h => h);
+ const headers = parseRow(lines[0]).filter((h) => h);
const data = [];
for (let i = 1; i < lines.length; i++) {
@@ -95,46 +95,54 @@ function parseCSV(filePath) {
return data;
}
-async function seedTable(pool, tableName, data, columns) {
- // Check if table has data
- const countResult = await pool.request().query(`SELECT COUNT(*) as count FROM ${tableName}`);
- if (countResult.recordset[0].count > 0) {
- console.log(` ${tableName}: already has ${countResult.recordset[0].count} rows, skipping`);
- return;
- }
-
- console.log(` ${tableName}: inserting ${data.length} rows...`);
-
- for (const row of data) {
- const cols = columns.filter(c => row[c] !== null && row[c] !== undefined);
- const vals = cols.map(c => {
- const v = row[c];
- if (typeof v === 'boolean') return v ? 1 : 0;
- if (typeof v === 'string') return `'${v.replace(/'/g, "''")}'`;
- return v;
- });
-
- const sql = `SET IDENTITY_INSERT ${tableName} ON; INSERT INTO ${tableName} (${cols.join(',')}) VALUES (${vals.join(',')}); SET IDENTITY_INSERT ${tableName} OFF;`;
- try {
- await pool.request().query(sql);
- } catch (e) {
- // Ignore duplicate key errors silently
- if (!e.message.includes('duplicate key')) {
- console.log(` Error row ${row.id}: ${e.message.substring(0, 60)}`);
+async function seedTable(client, tableName, data, columns) {
+ // Insert rows only when the table is empty (keeps re-runs idempotent)
+ const countResult = await client.query(`SELECT COUNT(*) AS count FROM ${quoteIdent(tableName)}`);
+ if (Number(countResult.rows[0].count) > 0) {
+ console.log(` ${tableName}: already has ${countResult.rows[0].count} rows, skipping insert`);
+ } else {
+ console.log(` ${tableName}: inserting ${data.length} rows...`);
+
+ for (const row of data) {
+ const cols = columns.filter((c) => row[c] !== null && row[c] !== undefined);
+ const vals = cols.map((c) => {
+ const v = row[c];
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
+ if (typeof v === 'string') return `'${v.replace(/'/g, "''")}'`;
+ return v;
+ });
+
+ const sql = `INSERT INTO ${quoteIdent(tableName)} (${cols.map(quoteIdent).join(', ')}) VALUES (${vals.join(', ')})`;
+ try {
+ await client.query(sql);
+ } catch (e) {
+ // Ignore duplicate key errors silently
+ if (!e.message.includes('duplicate key')) {
+ console.log(` Error row ${row.id}: ${e.message.substring(0, 60)}`);
+ }
}
}
}
+
+ // Always advance the identity sequence past the highest id. Rows are inserted with
+ // explicit ids, which do not bump the sequence, so a later auto-generated insert
+ // would otherwise collide. Running this unconditionally keeps the sequence correct
+ // even if an earlier run inserted rows but exited before reaching this point.
+ await client.query(
+ `SELECT setval(pg_get_serial_sequence($1, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${quoteIdent(tableName)}))`,
+ [tableName],
+ );
}
-async function waitForTables(pool, maxRetries = 30) {
+async function waitForTables(client, maxRetries = 30) {
for (let i = 0; i < maxRetries; i++) {
try {
- await pool.request().query('SELECT TOP 1 * FROM language');
+ await client.query('SELECT 1 FROM language LIMIT 1');
return true;
} catch (e) {
if (i < maxRetries - 1) {
console.log(` Waiting for tables... (${i + 1}/${maxRetries})`);
- await new Promise(r => setTimeout(r, 2000));
+ await new Promise((r) => setTimeout(r, 2000));
}
}
}
@@ -145,9 +153,10 @@ async function main() {
const seedDir = __dirname;
console.log('Connecting to database...');
- let pool;
+ let client;
try {
- pool = await mssql.connect(config);
+ client = new Client(config);
+ await client.connect();
} catch (e) {
console.log('Could not connect to database:', e.message);
console.log('Skipping seed (database may not be ready yet)');
@@ -155,10 +164,10 @@ async function main() {
}
console.log('Waiting for tables to be created...');
- const tablesReady = await waitForTables(pool);
+ const tablesReady = await waitForTables(client);
if (!tablesReady) {
console.log('Tables not ready after timeout. Run "npm run seed" after API starts.');
- await pool.close();
+ await client.end();
process.exit(0);
}
@@ -166,88 +175,186 @@ async function main() {
// Wallet (must be first - other tables depend on it)
const walletData = parseCSV(path.join(seedDir, 'wallet.csv'));
- await seedTable(pool, 'wallet', walletData, ['id', 'address', 'name', 'isKycClient', 'displayName', 'autoTradeApproval', 'usesDummyAddresses', 'displayFraudWarning']);
+ await seedTable(client, 'wallet', walletData, [
+ 'id',
+ 'address',
+ 'name',
+ 'isKycClient',
+ 'displayName',
+ 'autoTradeApproval',
+ 'usesDummyAddresses',
+ 'displayFraudWarning',
+ ]);
// Language
const langData = parseCSV(path.join(seedDir, 'language.csv'));
- await seedTable(pool, 'language', langData, ['id', 'symbol', 'name', 'foreignName', 'enable']);
+ await seedTable(client, 'language', langData, ['id', 'symbol', 'name', 'foreignName', 'enable']);
// PriceRule (must be before Fiat and Asset due to FK constraints)
const priceRuleData = parseCSV(path.join(seedDir, 'price_rule.csv'));
- await seedTable(pool, 'price_rule', priceRuleData, ['id', 'priceSource', 'priceAsset', 'priceReference', 'check1Source', 'check1Asset', 'check1Reference', 'check1Limit', 'check2Source', 'check2Asset', 'check2Reference', 'check2Limit', 'currentPrice', 'priceValiditySeconds', 'assetDisplayName', 'referenceDisplayName']);
+ await seedTable(client, 'price_rule', priceRuleData, [
+ 'id',
+ 'priceSource',
+ 'priceAsset',
+ 'priceReference',
+ 'check1Source',
+ 'check1Asset',
+ 'check1Reference',
+ 'check1Limit',
+ 'check2Source',
+ 'check2Asset',
+ 'check2Reference',
+ 'check2Limit',
+ 'currentPrice',
+ 'priceValiditySeconds',
+ 'assetDisplayName',
+ 'referenceDisplayName',
+ ]);
// Fiat
const fiatData = parseCSV(path.join(seedDir, 'fiat.csv'));
- await seedTable(pool, 'fiat', fiatData, ['id', 'name', 'buyable', 'sellable', 'cardBuyable', 'cardSellable', 'instantBuyable', 'instantSellable', 'approxPriceChf', 'priceRuleId']);
+ await seedTable(client, 'fiat', fiatData, [
+ 'id',
+ 'name',
+ 'buyable',
+ 'sellable',
+ 'cardBuyable',
+ 'cardSellable',
+ 'instantBuyable',
+ 'instantSellable',
+ 'approxPriceChf',
+ 'priceRuleId',
+ ]);
// Country
const countryData = parseCSV(path.join(seedDir, 'country.csv'));
- await seedTable(pool, 'country', countryData, ['id', 'symbol', 'name', 'symbol3', 'dfxEnable', 'dfxOrganizationEnable', 'lockEnable', 'ipEnable', 'yapealEnable', 'fatfEnable', 'nationalityEnable', 'nationalityStepEnable', 'bankTransactionVerificationEnable', 'bankEnable', 'cryptoEnable', 'checkoutEnable']);
+ await seedTable(client, 'country', countryData, [
+ 'id',
+ 'symbol',
+ 'name',
+ 'symbol3',
+ 'dfxEnable',
+ 'dfxOrganizationEnable',
+ 'lockEnable',
+ 'ipEnable',
+ 'yapealEnable',
+ 'fatfEnable',
+ 'nationalityEnable',
+ 'nationalityStepEnable',
+ 'bankTransactionVerificationEnable',
+ 'bankEnable',
+ 'cryptoEnable',
+ 'checkoutEnable',
+ ]);
// Asset - drop unique index that conflicts with NULL dexName values
try {
- await pool.request().query('DROP INDEX IF EXISTS IDX_83f52471fd746482b83b20f51b ON asset');
- } catch (e) { /* Index may not exist */ }
+ await client.query('DROP INDEX IF EXISTS "IDX_83f52471fd746482b83b20f51b"');
+ } catch (e) {
+ /* Index may not exist */
+ }
const assetData = parseCSV(path.join(seedDir, 'asset.csv'));
- await seedTable(pool, 'asset', assetData, [
- 'id', 'name', 'type', 'buyable', 'sellable', 'chainId', 'sellCommand', 'dexName',
- 'category', 'blockchain', 'uniqueName', 'description', 'comingSoon', 'sortOrder',
- 'approxPriceUsd', 'ikna', 'priceRuleId', 'approxPriceChf', 'cardBuyable', 'cardSellable',
- 'instantBuyable', 'instantSellable', 'financialType', 'decimals', 'paymentEnabled',
- 'amlRuleFrom', 'amlRuleTo', 'approxPriceEur', 'refundEnabled'
+ await seedTable(client, 'asset', assetData, [
+ 'id',
+ 'name',
+ 'type',
+ 'buyable',
+ 'sellable',
+ 'chainId',
+ 'sellCommand',
+ 'dexName',
+ 'category',
+ 'blockchain',
+ 'uniqueName',
+ 'description',
+ 'comingSoon',
+ 'sortOrder',
+ 'approxPriceUsd',
+ 'ikna',
+ 'priceRuleId',
+ 'approxPriceChf',
+ 'cardBuyable',
+ 'cardSellable',
+ 'instantBuyable',
+ 'instantSellable',
+ 'financialType',
+ 'decimals',
+ 'paymentEnabled',
+ 'amlRuleFrom',
+ 'amlRuleTo',
+ 'approxPriceEur',
+ 'refundEnabled',
]);
// IpLog (required for auth to work - needs at least one entry with old created date)
const oldDate = new Date();
oldDate.setFullYear(oldDate.getFullYear() - 1);
- const ipLogCount = await pool.request().query('SELECT COUNT(*) as count FROM ip_log');
- if (ipLogCount.recordset[0].count === 0) {
+ const ipLogCount = await client.query('SELECT COUNT(*) AS count FROM ip_log');
+ if (Number(ipLogCount.rows[0].count) === 0) {
console.log(' ip_log: inserting 1 rows...');
- await pool.request()
- .input('address', mssql.NVarChar, '0x0000000000000000000000000000000000000000')
- .input('ip', mssql.NVarChar, '127.0.0.1')
- .input('country', mssql.NVarChar, 'CH')
- .input('url', mssql.NVarChar, '/v1/auth')
- .input('result', mssql.Bit, 1)
- .input('created', mssql.DateTime2, oldDate)
- .input('updated', mssql.DateTime2, oldDate)
- .query('INSERT INTO ip_log (address, ip, country, url, result, created, updated) VALUES (@address, @ip, @country, @url, @result, @created, @updated)');
+ await client.query(
+ `INSERT INTO ip_log (address, ip, country, url, result, created, updated)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ ['0x0000000000000000000000000000000000000000', '127.0.0.1', 'CH', '/v1/auth', true, oldDate, oldDate],
+ );
} else {
- console.log(` ip_log: already has ${ipLogCount.recordset[0].count} rows, fixing oldest entry date...`);
+ console.log(` ip_log: already has ${ipLogCount.rows[0].count} rows, fixing oldest entry date...`);
// Ensure at least one entry has an old date (required for IpLogService.updateUserIpLogs)
- await pool.request()
- .input('oldDate', mssql.DateTime2, oldDate)
- .query('UPDATE ip_log SET created = @oldDate, updated = @oldDate WHERE id = (SELECT MIN(id) FROM ip_log)');
+ await client.query('UPDATE ip_log SET created = $1, updated = $1 WHERE id = (SELECT MIN(id) FROM ip_log)', [
+ oldDate,
+ ]);
}
// Fee (required for transaction fees)
const feeData = parseCSV(path.join(seedDir, 'fee.csv'));
- await seedTable(pool, 'fee', feeData, ['id', 'label', 'type', 'rate', 'accountType', 'active', 'fixed', 'payoutRefBonus', 'blockchainFactor', 'paymentMethodsIn', 'paymentMethodsOut']);
+ await seedTable(client, 'fee', feeData, [
+ 'id',
+ 'label',
+ 'type',
+ 'rate',
+ 'accountType',
+ 'active',
+ 'fixed',
+ 'payoutRefBonus',
+ 'blockchainFactor',
+ 'paymentMethodsIn',
+ 'paymentMethodsOut',
+ ]);
// Bank (required for payment processing)
const bankData = parseCSV(path.join(seedDir, 'bank.csv'));
- await seedTable(pool, 'bank', bankData, ['id', 'name', 'iban', 'bic', 'currency', 'receive', 'send', 'sctInst', 'amlEnabled']);
+ await seedTable(client, 'bank', bankData, [
+ 'id',
+ 'name',
+ 'iban',
+ 'bic',
+ 'currency',
+ 'receive',
+ 'send',
+ 'sctInst',
+ 'amlEnabled',
+ ]);
// Fix fiat priceRuleId links (always run to ensure consistency)
console.log(' Fixing fiat price rule links...');
const fiatRuleMapping = { CHF: 2, EUR: 39, USD: 1, AED: 45 };
for (const [fiatName, ruleId] of Object.entries(fiatRuleMapping)) {
- await pool.request()
- .input('ruleId', mssql.Int, ruleId)
- .input('name', mssql.NVarChar, fiatName)
- .query('UPDATE fiat SET priceRuleId = @ruleId WHERE name = @name AND (priceRuleId IS NULL OR priceRuleId != @ruleId)');
+ await client.query(
+ 'UPDATE fiat SET "priceRuleId" = $1 WHERE name = $2 AND ("priceRuleId" IS NULL OR "priceRuleId" != $1)',
+ [ruleId, fiatName],
+ );
}
// Note: Deposit addresses are NOT seeded here.
// They must be created via the API (/deposit endpoint) to ensure Alchemy webhook registration.
// Run 'npm run setup' after API starts to create deposits properly.
- await pool.close();
+ await client.end();
console.log('Seed complete!');
}
-main().catch(e => {
+main().catch((e) => {
console.error('Seed failed:', e.message);
process.exit(1);
});
diff --git a/scripts/setup.js b/scripts/setup.js
index c75d79ab05..59d57bd061 100644
--- a/scripts/setup.js
+++ b/scripts/setup.js
@@ -4,7 +4,7 @@
* DFX API Local Development Setup
*
* This script handles the complete local setup:
- * 1. Generates all wallet seeds (19 seeds/keys)
+ * 1. Generates all wallet seeds/keys
* 2. Starts API in background
* 3. Registers admin user via /auth endpoint
* 4. Sets admin role in database
@@ -16,9 +16,10 @@
const fs = require('fs');
const path = require('path');
+const crypto = require('crypto');
const { spawn } = require('child_process');
const { ethers } = require('ethers');
-const mssql = require('mssql');
+const { Client } = require('pg');
// ============================================================================
// SAFETY CHECKS - Prevent accidental execution against production
@@ -35,15 +36,10 @@ if (!allowedEnvironments.includes(currentEnv)) {
}
const dbHost = process.env.SQL_HOST || 'localhost';
-const allowedHostPatterns = [
- 'localhost',
- '127.0.0.1',
- /^sql-dfx-api-loc/i,
- /loc.*\.database\.windows\.net/i,
-];
-
-const isHostAllowed = allowedHostPatterns.some(pattern =>
- pattern instanceof RegExp ? pattern.test(dbHost) : dbHost === pattern
+const allowedHostPatterns = ['localhost', '127.0.0.1', /^sql-dfx-api-loc/i, /loc.*\.database\.windows\.net/i];
+
+const isHostAllowed = allowedHostPatterns.some((pattern) =>
+ pattern instanceof RegExp ? pattern.test(dbHost) : dbHost === pattern,
);
if (!isHostAllowed) {
@@ -89,7 +85,6 @@ function logInfo(message) {
log(` ${message}`, colors.yellow);
}
-
function generateMnemonic() {
const wallet = ethers.Wallet.createRandom();
return wallet.mnemonic.phrase;
@@ -143,37 +138,38 @@ async function waitForApi(maxRetries = 60, delayMs = 2000) {
if (i < maxRetries - 1) {
process.stdout.write(`\r Waiting for API... (${i + 1}/${maxRetries})`);
- await new Promise(r => setTimeout(r, delayMs));
+ await new Promise((r) => setTimeout(r, delayMs));
}
}
console.log('');
return false;
}
+function dbConfig() {
+ return {
+ user: process.env.SQL_USERNAME || 'sa',
+ password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL',
+ host: process.env.SQL_HOST || 'localhost',
+ port: parseInt(process.env.SQL_PORT) || 5432,
+ database: process.env.SQL_DB || 'dfx',
+ // Local-only script (host is restricted above); default to no SSL, opt in via SQL_SSL=true.
+ ssl: process.env.SQL_SSL === 'true' ? { rejectUnauthorized: false } : false,
+ };
+}
+
/**
* Wait for critical database tables to be created by TypeORM synchronization
* and for the seed to complete. This prevents race conditions where we try
* to register a user before tables exist.
*/
async function waitForDatabaseReady(maxRetries = 60, delayMs = 2000) {
- const config = {
- user: process.env.SQL_USERNAME || 'sa',
- password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL',
- server: process.env.SQL_HOST || 'localhost',
- port: parseInt(process.env.SQL_PORT) || 1433,
- database: process.env.SQL_DB || 'dfx',
- options: {
- encrypt: process.env.SQL_ENCRYPT === 'true',
- trustServerCertificate: true,
- },
- };
-
// Critical tables that must exist for auth to work
const requiredTables = ['user', 'user_data', 'wallet', 'ip_log', 'language', 'country', 'fiat', 'asset'];
- let pool;
+ let client;
try {
- pool = await mssql.connect(config);
+ client = new Client(dbConfig());
+ await client.connect();
} catch (e) {
logError(`Could not connect to database: ${e.message}`);
return false;
@@ -182,43 +178,49 @@ async function waitForDatabaseReady(maxRetries = 60, delayMs = 2000) {
for (let i = 0; i < maxRetries; i++) {
try {
// Check if all required tables exist
- const tableCheck = await pool.request().query(`
- SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
- WHERE TABLE_TYPE = 'BASE TABLE'
- AND TABLE_NAME IN (${requiredTables.map(t => `'${t}'`).join(',')})
- `);
+ const tableCheck = await client.query(
+ `SELECT table_name FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name = ANY($1)`,
+ [requiredTables],
+ );
- const existingTables = tableCheck.recordset.map(r => r.TABLE_NAME);
- const missingTables = requiredTables.filter(t => !existingTables.includes(t));
+ const existingTables = tableCheck.rows.map((r) => r.table_name);
+ const missingTables = requiredTables.filter((t) => !existingTables.includes(t));
if (missingTables.length === 0) {
// All tables exist, now check if seed data is present
- const seedCheck = await pool.request().query(`
- SELECT
- (SELECT COUNT(*) FROM wallet) as wallets,
- (SELECT COUNT(*) FROM language) as languages,
- (SELECT COUNT(*) FROM ip_log) as ipLogs
- `);
+ const seedCheck = await client.query(
+ `SELECT
+ (SELECT COUNT(*) FROM wallet) AS wallets,
+ (SELECT COUNT(*) FROM language) AS languages,
+ (SELECT COUNT(*) FROM ip_log) AS ip_logs`,
+ );
- const { wallets, languages, ipLogs } = seedCheck.recordset[0];
+ const wallets = Number(seedCheck.rows[0].wallets);
+ const languages = Number(seedCheck.rows[0].languages);
+ const ipLogs = Number(seedCheck.rows[0].ip_logs);
if (wallets > 0 && languages > 0 && ipLogs > 0) {
- await pool.close();
+ await client.end();
return true;
}
- process.stdout.write(`\r Waiting for seed data... (${i + 1}/${maxRetries}) [wallet:${wallets}, language:${languages}, ip_log:${ipLogs}]`);
+ process.stdout.write(
+ `\r Waiting for seed data... (${i + 1}/${maxRetries}) [wallet:${wallets}, language:${languages}, ip_log:${ipLogs}]`,
+ );
} else {
- process.stdout.write(`\r Waiting for tables... (${i + 1}/${maxRetries}) [missing: ${missingTables.slice(0, 3).join(', ')}${missingTables.length > 3 ? '...' : ''}]`);
+ process.stdout.write(
+ `\r Waiting for tables... (${i + 1}/${maxRetries}) [missing: ${missingTables.slice(0, 3).join(', ')}${missingTables.length > 3 ? '...' : ''}]`,
+ );
}
} catch (e) {
process.stdout.write(`\r Waiting for database... (${i + 1}/${maxRetries})`);
}
- await new Promise(r => setTimeout(r, delayMs));
+ await new Promise((r) => setTimeout(r, delayMs));
}
- await pool.close();
+ await client.end();
console.log('');
return false;
}
@@ -267,51 +269,23 @@ async function registerUser(address, signature) {
}
async function setAdminRole(address) {
- const config = {
- user: process.env.SQL_USERNAME || 'sa',
- password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL',
- server: process.env.SQL_HOST || 'localhost',
- port: parseInt(process.env.SQL_PORT) || 1433,
- database: process.env.SQL_DB || 'dfx',
- options: {
- encrypt: process.env.SQL_ENCRYPT === 'true',
- trustServerCertificate: true,
- },
- };
-
- const pool = await mssql.connect(config);
-
- // Set role to Admin
- await pool.request()
- .input('address', mssql.NVarChar, address)
- .input('role', mssql.NVarChar, 'Admin')
- .query('UPDATE [user] SET role = @role WHERE address = @address');
+ const client = new Client(dbConfig());
+ await client.connect();
- // Verify
- const result = await pool.request()
- .input('address', mssql.NVarChar, address)
- .query('SELECT id, address, role FROM [user] WHERE address = @address');
+ try {
+ // Set role to Admin
+ await client.query('UPDATE "user" SET role = $1 WHERE address = $2', ['Admin', address]);
- await pool.close();
+ // Verify
+ const result = await client.query('SELECT id, address, role FROM "user" WHERE address = $1', [address]);
- return result.recordset[0];
+ return result.rows[0];
+ } finally {
+ await client.end();
+ }
}
async function seedDepositAddresses(adminSeed, count) {
- const config = {
- user: process.env.SQL_USERNAME || 'sa',
- password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL',
- server: process.env.SQL_HOST || 'localhost',
- port: parseInt(process.env.SQL_PORT) || 1433,
- database: process.env.SQL_DB || 'dfx',
- options: {
- encrypt: process.env.SQL_ENCRYPT === 'true',
- trustServerCertificate: true,
- },
- };
-
- const pool = await mssql.connect(config);
-
// Generate deposit addresses from EVM_DEPOSIT_SEED
const evmDepositSeed = readEnvValue('EVM_DEPOSIT_SEED');
if (!evmDepositSeed) {
@@ -320,37 +294,48 @@ async function seedDepositAddresses(adminSeed, count) {
// EVM blockchains that share deposit addresses (semicolon-separated)
const evmBlockchains = [
- 'Ethereum', 'Sepolia', 'BinanceSmartChain', 'Arbitrum',
- 'Optimism', 'Polygon', 'Base', 'Gnosis', 'Haqq', 'CitreaTestnet'
+ 'Ethereum',
+ 'Sepolia',
+ 'BinanceSmartChain',
+ 'Arbitrum',
+ 'Optimism',
+ 'Polygon',
+ 'Base',
+ 'Gnosis',
+ 'Haqq',
+ 'CitreaTestnet',
];
const blockchainsStr = evmBlockchains.join(';');
- let insertedCount = 0;
+ const client = new Client(dbConfig());
+ await client.connect();
- for (let i = 0; i < count; i++) {
- const hdPath = `m/44'/60'/0'/0/${i}`;
- const wallet = ethers.Wallet.fromMnemonic(evmDepositSeed, hdPath);
- const address = wallet.address;
+ let insertedCount = 0;
- try {
- const result = await pool.request()
- .input('address', mssql.NVarChar, address)
- .input('blockchains', mssql.NVarChar, blockchainsStr)
- .input('accountIndex', mssql.Int, i)
- .query(`
- IF NOT EXISTS (SELECT 1 FROM deposit WHERE address = @address)
- INSERT INTO deposit (address, blockchains, accountIndex, created, updated)
- VALUES (@address, @blockchains, @accountIndex, GETDATE(), GETDATE())
- `);
- if (result.rowsAffected[0] > 0) {
- insertedCount++;
+ try {
+ for (let i = 0; i < count; i++) {
+ const hdPath = `m/44'/60'/0'/0/${i}`;
+ const wallet = ethers.Wallet.fromMnemonic(evmDepositSeed, hdPath);
+ const address = wallet.address;
+
+ try {
+ const result = await client.query(
+ `INSERT INTO deposit (address, blockchains, "accountIndex", created, updated)
+ SELECT $1::text, $2::text, $3::int, NOW(), NOW()
+ WHERE NOT EXISTS (SELECT 1 FROM deposit WHERE address = $1)`,
+ [address, blockchainsStr, i],
+ );
+ if (result.rowCount > 0) {
+ insertedCount++;
+ }
+ } catch (e) {
+ // Ignore duplicates
}
- } catch (e) {
- // Ignore duplicates
}
+ } finally {
+ await client.end();
}
- await pool.close();
return insertedCount;
}
@@ -369,10 +354,14 @@ async function main() {
'SOLANA_WALLET_SEED',
'TRON_WALLET_SEED',
'CARDANO_WALLET_SEED',
+ 'ICP_WALLET_SEED',
+ 'SPARK_WALLET_SEED',
+ 'BOLTZ_SEED',
'PAYMENT_EVM_SEED',
'PAYMENT_SOLANA_SEED',
'PAYMENT_TRON_SEED',
'PAYMENT_CARDANO_SEED',
+ 'PAYMENT_ICP_SEED',
];
const privateKeysToGenerate = [
@@ -384,6 +373,7 @@ async function main() {
'ARBITRUM_WALLET_PRIVATE_KEY',
'POLYGON_WALLET_PRIVATE_KEY',
'GNOSIS_WALLET_PRIVATE_KEY',
+ 'CITREA_WALLET_PRIVATE_KEY',
'CITREA_TESTNET_WALLET_PRIVATE_KEY',
];
@@ -420,6 +410,7 @@ async function main() {
'ARBITRUM_WALLET_ADDRESS',
'POLYGON_WALLET_ADDRESS',
'GNOSIS_WALLET_ADDRESS',
+ 'CITREA_WALLET_ADDRESS',
'CITREA_TESTNET_WALLET_ADDRESS',
];
@@ -430,6 +421,31 @@ async function main() {
}
}
+ // Service keys with non-EVM formats (each blockchain/integration service parses
+ // its key at startup, so a missing or wrongly-formatted value crashes the boot).
+
+ // Ark identity key: raw hex private key, consumed via SingleKey.fromHex().
+ if (!readEnvValue('ARK_PRIVATE_KEY')) {
+ updateEnvFile({ ARK_PRIVATE_KEY: crypto.randomBytes(32).toString('hex') });
+ generatedCount++;
+ }
+
+ // KuCoin Pay signing key: PKCS#8 key as base64-encoded DER, consumed via crypto.createPrivateKey().
+ if (!readEnvValue('DFX_KUCOINPAY_PRIVATE_KEY')) {
+ const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
+ const der = privateKey.export({ type: 'pkcs8', format: 'der' });
+ updateEnvFile({ DFX_KUCOINPAY_PRIVATE_KEY: der.toString('base64') });
+ generatedCount++;
+ }
+
+ // Payment webhook signing key: PKCS#8 PEM, with newlines encoded as
to fit on one .env line.
+ if (!readEnvValue('PAYMENT_WEBHOOK_PRIVATE_KEY')) {
+ const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
+ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).trim();
+ updateEnvFile({ PAYMENT_WEBHOOK_PRIVATE_KEY: pem.split('\n').join('
') });
+ generatedCount++;
+ }
+
if (generatedCount > 0) {
logSuccess(`Generated ${generatedCount} wallet seeds/keys`);
} else {
@@ -501,12 +517,11 @@ async function main() {
registrationSuccess = true;
break;
-
} catch (error) {
if (attempt < maxRegistrationRetries) {
logInfo(`Attempt ${attempt}/${maxRegistrationRetries} failed: ${error.message.substring(0, 50)}...`);
logInfo('Retrying in 3 seconds...');
- await new Promise(r => setTimeout(r, 3000));
+ await new Promise((r) => setTimeout(r, 3000));
} else {
logError(`Failed to register admin after ${maxRegistrationRetries} attempts: ${error.message}`);
process.exit(1);
diff --git a/scripts/setup.sh b/scripts/setup.sh
deleted file mode 100755
index 156a6764e2..0000000000
--- a/scripts/setup.sh
+++ /dev/null
@@ -1,127 +0,0 @@
-#!/bin/bash
-set -e
-
-echo "🚀 DFX API Local Development Setup"
-echo "===================================="
-echo ""
-
-# Check prerequisites
-if ! command -v docker &> /dev/null; then
- echo "❌ Docker not installed. Please install Docker Desktop from https://www.docker.com/products/docker-desktop"
- exit 1
-fi
-
-if ! command -v node &> /dev/null; then
- echo "❌ Node.js not installed. Please install Node.js LTS from https://nodejs.org"
- exit 1
-fi
-
-# Check if Docker is running, start if needed
-if ! docker info &> /dev/null; then
- echo "❌ Docker is not running"
- echo ""
-
- # Try to start Docker automatically (macOS only)
- if [[ "$OSTYPE" == "darwin"* ]]; then
- echo "📦 Attempting to start Docker Desktop..."
- open -a Docker
-
- # Wait for Docker to start (max 60 seconds)
- echo "⏳ Waiting for Docker to start..."
- for i in {1..30}; do
- if docker info &> /dev/null; then
- echo "✅ Docker is ready"
- break
- fi
- if [ $i -eq 30 ]; then
- echo "❌ Docker failed to start within 60 seconds"
- echo "Please start Docker Desktop manually and run this script again"
- exit 1
- fi
- sleep 2
- echo -n "."
- done
- else
- # Linux/Windows - cannot auto-start
- echo "Please start Docker and run this script again:"
- echo ""
- echo " Linux: sudo systemctl start docker"
- echo " Windows: Start Docker Desktop from Start Menu"
- echo ""
- exit 1
- fi
-else
- echo "✅ Docker is running"
-fi
-
-# Install dependencies
-if [ ! -d "node_modules" ]; then
- echo ""
- echo "📥 Installing npm dependencies..."
- npm install
-else
- echo "✅ Dependencies already installed"
-fi
-
-# Setup environment
-if [ ! -f .env ]; then
- echo ""
- echo "⚙️ Creating .env from template..."
- cp .env.local.example .env
- echo "✅ .env file created"
-else
- echo "✅ .env file already exists"
-fi
-
-# Start database
-echo ""
-echo "🗄️ Starting database..."
-docker compose up -d
-
-# Wait for database initialization
-echo "⏳ Waiting for database initialization..."
-for i in {1..30}; do
- if docker compose logs db-init 2>&1 | grep -q "Database 'dfx' ready"; then
- echo "✅ Database ready"
- break
- fi
- if [ $i -eq 30 ]; then
- echo "❌ Database failed to initialize within 60 seconds"
- echo "Run 'docker-compose logs' to see what went wrong"
- exit 1
- fi
- sleep 2
- echo -n "."
-done
-
-# Seed test data
-echo ""
-echo "🌱 Seeding test data..."
-if [ -f "scripts/testdata.js" ]; then
- node scripts/testdata.js
- echo "✅ Test data seeded"
-else
- echo "⚠️ testdata.js not found, skipping"
-fi
-
-echo ""
-echo "🔐 Seeding KYC test data..."
-if [ -f "scripts/kyc/kyc-testdata.js" ]; then
- node scripts/kyc/kyc-testdata.js
- echo "✅ KYC test data seeded"
-else
- echo "⚠️ kyc-testdata.js not found, skipping"
-fi
-
-echo ""
-echo "✅ Setup complete!"
-echo ""
-echo "🎯 To start the API server, run:"
-echo " npm start"
-echo ""
-echo "📝 The server will be available at: http://localhost:3000"
-echo "📝 All external services are automatically mocked in local mode"
-echo ""
-echo "📁 To upload KYC files (after API is running), run:"
-echo " ./scripts/kyc/upload-kyc-files.sh"
-echo ""
diff --git a/src/config/__tests__/config-bootstrap-order.spec.ts b/src/config/__tests__/config-bootstrap-order.spec.ts
new file mode 100644
index 0000000000..347a127ca7
--- /dev/null
+++ b/src/config/__tests__/config-bootstrap-order.spec.ts
@@ -0,0 +1,36 @@
+import { createMock } from '@golevelup/ts-jest';
+import { Type } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import { Config } from 'src/config/config';
+import { FaucetRequestService } from 'src/subdomains/core/faucet-request/services/faucet-request.service';
+import { LnUrlForwardService } from 'src/subdomains/generic/forwarding/services/lnurl-forward.service';
+import { PricingRealUnitService } from 'src/subdomains/supporting/pricing/services/integration/pricing-realunit.service';
+import { RealUnitService } from 'src/subdomains/supporting/realunit/realunit.service';
+
+// Regression guard for the bootstrap-ordering crash: the exported `Config` singleton is
+// undefined until ConfigService is constructed. A provider that reads `Config` while being
+// constructed (e.g. in a field initializer) throws during dependency-injection if it is
+// instantiated before ConfigService - which once crashed the whole API on startup.
+//
+// Each provider is compiled in isolation, without ConfigService, so `Config` is still
+// undefined while the provider is constructed. This fails if construction reads `Config`.
+describe('Config bootstrap ordering', () => {
+ const providers: Type[] = [PricingRealUnitService, FaucetRequestService, RealUnitService, LnUrlForwardService];
+
+ it('reproduces the pre-bootstrap state (Config not yet initialized)', () => {
+ expect(Config).toBeUndefined();
+ });
+
+ it.each(providers.map((provider) => [provider.name, provider] as const))(
+ 'instantiates %s without reading Config at construction time',
+ async (_name, provider) => {
+ const moduleRef = await Test.createTestingModule({ providers: [provider] })
+ .useMocker(() => createMock())
+ .compile();
+
+ expect(moduleRef.get(provider)).toBeDefined();
+
+ await moduleRef.close();
+ },
+ );
+});
diff --git a/src/subdomains/core/faucet-request/services/faucet-request.service.ts b/src/subdomains/core/faucet-request/services/faucet-request.service.ts
index a74bd6b408..aecf71e7f7 100644
--- a/src/subdomains/core/faucet-request/services/faucet-request.service.ts
+++ b/src/subdomains/core/faucet-request/services/faucet-request.service.ts
@@ -30,9 +30,11 @@ export class FaucetRequestService {
private readonly userService: UserService,
) {}
private readonly logger = new DfxLogger(FaucetRequestService);
- private readonly faucetBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment)
- ? Blockchain.SEPOLIA
- : Blockchain.ETHEREUM;
+ // Getter, not a field: Config is undefined until ConfigService is constructed, so reading it
+ // in a field initializer can crash bootstrap depending on provider-instantiation order.
+ private get faucetBlockchain(): Blockchain {
+ return [Environment.DEV, Environment.LOC].includes(Config.environment) ? Blockchain.SEPOLIA : Blockchain.ETHEREUM;
+ }
@DfxCron(CronExpression.EVERY_5_MINUTES, { process: Process.CRYPTO_PAYOUT })
async checkFaucetRequests(): Promise {
diff --git a/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts b/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts
index d103e27f41..42102a90c7 100644
--- a/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts
+++ b/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts
@@ -23,8 +23,15 @@ import { PaymentDto } from '../dto/payment.dto';
@Injectable()
export class LnUrlForwardService {
- private readonly PAYMENT_LINK_PREFIX = `${Config.prefixes.paymentLinkUidPrefix}_`;
- private readonly PAYMENT_LINK_PAYMENT_PREFIX = `${Config.prefixes.paymentLinkPaymentUidPrefix}_`;
+ // Getters, not fields: Config is undefined until ConfigService is constructed, so reading it
+ // in a field initializer can crash bootstrap depending on provider-instantiation order.
+ private get PAYMENT_LINK_PREFIX(): string {
+ return `${Config.prefixes.paymentLinkUidPrefix}_`;
+ }
+
+ private get PAYMENT_LINK_PAYMENT_PREFIX(): string {
+ return `${Config.prefixes.paymentLinkPaymentUidPrefix}_`;
+ }
private readonly client: LightningClient;
diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
index 6c88ab96e0..ac87826230 100644
--- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
+++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts
@@ -1,5 +1,7 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
+import { Wallet } from 'ethers';
+import { verifyTypedData } from 'ethers/lib/utils';
import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service';
import { BrokerbotCurrency } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto';
import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service';
@@ -32,9 +34,16 @@ import { RealUnitDevService } from '../realunit-dev.service';
import { PriceSourceUnavailableException } from '../exceptions/price-source-unavailable.exception';
import { RealUnitService } from '../realunit.service';
+let mockEnvironment = 'loc';
+
jest.mock('src/config/config', () => ({
get Config() {
- return { environment: 'loc' };
+ return {
+ environment: mockEnvironment,
+ blockchain: {
+ realunit: { api: { url: 'https://mock-api.example.com', key: 'mock-key' } },
+ },
+ };
},
Environment: {
LOC: 'loc',
@@ -153,11 +162,12 @@ describe('RealUnitService', () => {
provide: KycService,
useValue: {
createCustomKycStep: jest.fn(),
+ saveKycStepUpdate: jest.fn(),
},
},
{ provide: CountryService, useValue: {} },
{ provide: LanguageService, useValue: {} },
- { provide: HttpService, useValue: {} },
+ { provide: HttpService, useValue: { post: jest.fn() } },
{ provide: FiatService, useValue: {} },
{ provide: BuyService, useValue: {} },
{
@@ -641,4 +651,156 @@ describe('RealUnitService', () => {
await expect((service as any).withPriceSourceGuard(() => Promise.resolve('ok'))).resolves.toBe('ok');
});
});
+
+ describe('forwardRegistration (forwards the signed representation to Aktionariat)', () => {
+ // Hardhat test accounts — synthetic keys, never real user wallets.
+ const softwareWallet = new Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d');
+ // Stands in for a BitBox the user adds later (hardware can only sign ASCII).
+ const hardwareWallet = new Wallet('0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a');
+
+ const domain = { name: 'RealUnitUser', version: '1' };
+ const types = {
+ RealUnitUser: [
+ { name: 'email', type: 'string' },
+ { name: 'name', type: 'string' },
+ { name: 'type', type: 'string' },
+ { name: 'phoneNumber', type: 'string' },
+ { name: 'birthday', type: 'string' },
+ { name: 'nationality', type: 'string' },
+ { name: 'addressStreet', type: 'string' },
+ { name: 'addressPostalCode', type: 'string' },
+ { name: 'addressCity', type: 'string' },
+ { name: 'addressCountry', type: 'string' },
+ { name: 'swissTaxResidence', type: 'bool' },
+ { name: 'registrationDate', type: 'string' },
+ { name: 'walletAddress', type: 'address' },
+ ],
+ };
+
+ // UTF-8 originals as persisted on the KYC step / user_data.
+ const utf8Fields = (walletAddress: string) => ({
+ email: 'erika.example@example.com',
+ name: 'Erika Müller',
+ type: 'HUMAN',
+ phoneNumber: '+41790000000',
+ birthday: '1990-01-01',
+ nationality: 'CH',
+ addressStreet: 'Bahnhofstrasse 1',
+ addressPostalCode: '8001',
+ addressCity: 'Zürich',
+ addressCountry: 'CH',
+ swissTaxResidence: true,
+ registrationDate: '2026-06-08',
+ walletAddress,
+ });
+
+ // BitBox-safe ASCII transliteration of the same fields — what the wallet signs.
+ const asciiFields = (walletAddress: string) => ({
+ ...utf8Fields(walletAddress),
+ name: 'Erika Mueller',
+ addressCity: 'Zuerich',
+ });
+
+ const buildDto = (fields: Record, signature: string): any => ({
+ ...fields,
+ signature,
+ lang: 'DE',
+ kycData: {},
+ });
+
+ const fakeKycStep = (): any => ({
+ id: 1,
+ userData: { kycLevel: 999 },
+ complete: jest.fn().mockReturnValue([1, {}]),
+ manualReview: jest.fn().mockReturnValue([1, {}]),
+ });
+
+ const forwardedPayload = (): any => ((service as any).http.post as jest.Mock).mock.calls[0][1];
+
+ // What Aktionariat does: recover the signer from the forwarded payload and compare to walletAddress.
+ const recoverFromForwarded = (p: any): string =>
+ verifyTypedData(
+ domain,
+ types,
+ {
+ email: p.email,
+ name: p.name,
+ type: p.type,
+ phoneNumber: p.phoneNumber,
+ birthday: p.birthday,
+ nationality: p.nationality,
+ addressStreet: p.addressStreet,
+ addressPostalCode: p.addressPostalCode,
+ addressCity: p.addressCity,
+ addressCountry: p.addressCountry,
+ swissTaxResidence: p.swissTaxResidence,
+ registrationDate: p.registrationDate,
+ walletAddress: p.walletAddress,
+ },
+ p.signature,
+ );
+
+ beforeEach(() => {
+ mockEnvironment = 'prd';
+ });
+
+ afterEach(() => {
+ mockEnvironment = 'loc';
+ });
+
+ // REGRESSION GUARD: a legacy software wallet that signed the raw UTF-8 fields
+ // (still accepted by verifyRealUnitRegistrationSignature) must keep working —
+ // the forward must stay UTF-8, not be transliterated, or Aktionariat rejects it.
+ it('forwards the raw UTF-8 fields unchanged when the wallet signed UTF-8 (legacy app)', async () => {
+ const wallet = softwareWallet.address;
+ const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet));
+ const dto = buildDto(utf8Fields(wallet), signature);
+
+ const ok = await (service as any).forwardRegistration(fakeKycStep(), dto);
+
+ expect(ok).toBe(true);
+ const payload = forwardedPayload();
+ expect(payload.name).toBe('Erika Müller');
+ expect(payload.addressCity).toBe('Zürich');
+ // Aktionariat re-verifies the signature against the payload it receives.
+ expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase());
+ });
+
+ it('forwards the BitBox-safe ASCII fields when the wallet signed ASCII (current app), even though the dto stores UTF-8', async () => {
+ const wallet = softwareWallet.address;
+ const signature = await softwareWallet._signTypedData(domain, types, asciiFields(wallet));
+ // dto carries the UTF-8 originals as stored; only the signature is over ASCII.
+ const dto = buildDto(utf8Fields(wallet), signature);
+
+ const ok = await (service as any).forwardRegistration(fakeKycStep(), dto);
+
+ expect(ok).toBe(true);
+ const payload = forwardedPayload();
+ expect(payload.name).toBe('Erika Mueller');
+ expect(payload.addressCity).toBe('Zuerich');
+ expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase());
+ });
+
+ it('supports the software→hardware switch: a BitBox-signed (ASCII-only) wallet verifies against the forwarded payload', async () => {
+ const wallet = hardwareWallet.address;
+ const signature = await hardwareWallet._signTypedData(domain, types, asciiFields(wallet));
+ const dto = buildDto(utf8Fields(wallet), signature);
+
+ const ok = await (service as any).forwardRegistration(fakeKycStep(), dto);
+
+ expect(ok).toBe(true);
+ const [url, payload] = ((service as any).http.post as jest.Mock).mock.calls[0];
+ expect(url).toContain('/registerUser');
+ expect(payload.name).toBe('Erika Mueller');
+ expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase());
+ });
+
+ it('resolveSignedRegistrationMessage returns undefined when a valid signature does not belong to the claimed wallet', async () => {
+ // Valid signature from the software wallet, but the dto claims a different wallet address.
+ const signature = await softwareWallet._signTypedData(domain, types, asciiFields(softwareWallet.address));
+ const dto = buildDto(utf8Fields(hardwareWallet.address), signature);
+
+ expect((service as any).resolveSignedRegistrationMessage(dto)).toBeUndefined();
+ });
+ });
});
diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts
index 13029bc276..de4390e88b 100644
--- a/src/subdomains/supporting/realunit/realunit.service.ts
+++ b/src/subdomains/supporting/realunit/realunit.service.ts
@@ -119,6 +119,45 @@ function matchesSignedField(kycValue: string | undefined, signedValue: string |
return toBitboxAscii(kycValue) === signedValue;
}
+const REGISTRATION_EIP712_DOMAIN = { name: 'RealUnitUser', version: '1' };
+
+const REGISTRATION_EIP712_TYPES = {
+ RealUnitUser: [
+ { name: 'email', type: 'string' },
+ { name: 'name', type: 'string' },
+ { name: 'type', type: 'string' },
+ { name: 'phoneNumber', type: 'string' },
+ { name: 'birthday', type: 'string' },
+ { name: 'nationality', type: 'string' },
+ { name: 'addressStreet', type: 'string' },
+ { name: 'addressPostalCode', type: 'string' },
+ { name: 'addressCity', type: 'string' },
+ { name: 'addressCountry', type: 'string' },
+ { name: 'swissTaxResidence', type: 'bool' },
+ { name: 'registrationDate', type: 'string' },
+ { name: 'walletAddress', type: 'address' },
+ ],
+};
+
+// The EIP-712 fields a registration signature is computed over, in the exact
+// representation that was signed (raw UTF-8 or BitBox-safe ASCII).
+type SignedRegistrationMessage = Pick<
+ AktionariatRegistrationDto,
+ | 'email'
+ | 'name'
+ | 'type'
+ | 'phoneNumber'
+ | 'birthday'
+ | 'nationality'
+ | 'addressStreet'
+ | 'addressPostalCode'
+ | 'addressCity'
+ | 'addressCountry'
+ | 'swissTaxResidence'
+ | 'registrationDate'
+ | 'walletAddress'
+>;
+
@Injectable()
export class RealUnitService {
private readonly logger = new DfxLogger(RealUnitService);
@@ -126,9 +165,11 @@ export class RealUnitService {
private readonly ponderUrl: string;
private readonly genesisDate = new Date('2022-04-12 07:46:41.000');
private readonly tokenName = 'REALU';
- private readonly tokenBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment)
- ? Blockchain.SEPOLIA
- : Blockchain.ETHEREUM;
+ // Getter, not a field: Config is undefined until ConfigService is constructed, so reading it
+ // in a field initializer can crash bootstrap depending on provider-instantiation order.
+ private get tokenBlockchain(): Blockchain {
+ return [Environment.DEV, Environment.LOC].includes(Config.environment) ? Blockchain.SEPOLIA : Blockchain.ETHEREUM;
+ }
private readonly historicalPriceCache = new AsyncCache(CacheItemResetPeriod.EVERY_6_HOURS);
constructor(
@@ -826,65 +867,49 @@ export class RealUnitService {
}
private verifyRealUnitRegistrationSignature(data: RealUnitRegistrationDto): boolean {
- const domain = {
- name: 'RealUnitUser',
- version: '1',
- };
+ return this.resolveSignedRegistrationMessage(data) != null;
+ }
- const types = {
- RealUnitUser: [
- { name: 'email', type: 'string' },
- { name: 'name', type: 'string' },
- { name: 'type', type: 'string' },
- { name: 'phoneNumber', type: 'string' },
- { name: 'birthday', type: 'string' },
- { name: 'nationality', type: 'string' },
- { name: 'addressStreet', type: 'string' },
- { name: 'addressPostalCode', type: 'string' },
- { name: 'addressCity', type: 'string' },
- { name: 'addressCountry', type: 'string' },
- { name: 'swissTaxResidence', type: 'bool' },
- { name: 'registrationDate', type: 'string' },
- { name: 'walletAddress', type: 'address' },
- ],
- };
+ // Builds the EIP-712 message in either the raw or the BitBox-safe ASCII
+ // representation. Only the free-text fields carry diacritics, so only those
+ // are transliterated — mirrors realunit-app's signing path (Krüger → Krueger).
+ private buildRegistrationMessage(data: RealUnitRegistrationDto, transliterate: boolean): SignedRegistrationMessage {
+ const ascii = (value: string): string => (transliterate ? toBitboxAscii(value) : value);
- const message = {
- email: data.email,
- name: data.name,
+ return {
+ email: ascii(data.email),
+ name: ascii(data.name),
type: data.type,
- phoneNumber: data.phoneNumber,
- birthday: data.birthday,
+ phoneNumber: ascii(data.phoneNumber),
+ birthday: ascii(data.birthday),
nationality: data.nationality,
- addressStreet: data.addressStreet,
- addressPostalCode: data.addressPostalCode,
- addressCity: data.addressCity,
+ addressStreet: ascii(data.addressStreet),
+ addressPostalCode: ascii(data.addressPostalCode),
+ addressCity: ascii(data.addressCity),
addressCountry: data.addressCountry,
swissTaxResidence: data.swissTaxResidence,
registrationDate: data.registrationDate,
walletAddress: data.walletAddress,
};
+ }
- const signatureToUse = data.signature.startsWith('0x') ? data.signature : `0x${data.signature}`;
- const recoveredAddress = verifyTypedData(domain, types, message, signatureToUse);
- if (Util.equalsIgnoreCase(recoveredAddress, data.walletAddress)) return true;
-
- // Backwards-compat: app v0.0.3+ signs BitBox-safe ASCII. If the stored
- // accountData still holds UTF-8 from a pre-transliteration registration,
- // retry verify with the same fields transliterated so re-login (add new
- // wallet) keeps working for those users.
- const asciiMessage = {
- ...message,
- email: toBitboxAscii(message.email),
- name: toBitboxAscii(message.name),
- phoneNumber: toBitboxAscii(message.phoneNumber),
- birthday: toBitboxAscii(message.birthday),
- addressStreet: toBitboxAscii(message.addressStreet),
- addressPostalCode: toBitboxAscii(message.addressPostalCode),
- addressCity: toBitboxAscii(message.addressCity),
- };
- const asciiRecovered = verifyTypedData(domain, types, asciiMessage, signatureToUse);
- return Util.equalsIgnoreCase(asciiRecovered, data.walletAddress);
+ // Returns the EIP-712 fields exactly as the wallet signed them — raw UTF-8
+ // (legacy software wallets, kept working by #3709) or BitBox-safe ASCII
+ // (current app / any BitBox, whose firmware rejects non-ASCII bytes). Returns
+ // undefined if the signature matches neither. Aktionariat re-verifies the
+ // signature against the payload we POST in forwardRegistration, so the
+ // forwarded bytes must be exactly these — forwarding any other variant fails
+ // as "Invalid signature".
+ private resolveSignedRegistrationMessage(data: RealUnitRegistrationDto): SignedRegistrationMessage | undefined {
+ const signature = data.signature.startsWith('0x') ? data.signature : `0x${data.signature}`;
+
+ for (const transliterate of [false, true]) {
+ const message = this.buildRegistrationMessage(data, transliterate);
+ const recovered = verifyTypedData(REGISTRATION_EIP712_DOMAIN, REGISTRATION_EIP712_TYPES, message, signature);
+ if (Util.equalsIgnoreCase(recovered, data.walletAddress)) return message;
+ }
+
+ return undefined;
}
async forwardRegistrationToAktionariat(kycStepId: number): Promise {
@@ -1079,21 +1104,14 @@ export class RealUnitService {
const { api } = Config.blockchain.realunit;
try {
- // forward only Aktionariat fields (exclude kycData to avoid signature verification issues)
+ // forward only Aktionariat fields (exclude kycData to avoid signature verification issues).
+ // Aktionariat re-verifies the EIP-712 signature against this payload, so send back the exact
+ // representation that was signed — raw UTF-8 (legacy software wallets) or BitBox-safe ASCII
+ // (current app / BitBox). Forwarding the wrong variant fails as "Invalid signature". The
+ // UTF-8 originals stay on user_data for PDF/mail.
+ const signedMessage = this.resolveSignedRegistrationMessage(dto) ?? this.buildRegistrationMessage(dto, false);
const payload: AktionariatRegistrationDto = {
- email: dto.email,
- name: dto.name,
- type: dto.type,
- phoneNumber: dto.phoneNumber,
- birthday: dto.birthday,
- nationality: dto.nationality,
- addressStreet: dto.addressStreet,
- addressPostalCode: dto.addressPostalCode,
- addressCity: dto.addressCity,
- addressCountry: dto.addressCountry,
- swissTaxResidence: dto.swissTaxResidence,
- registrationDate: dto.registrationDate,
- walletAddress: dto.walletAddress,
+ ...signedMessage,
signature: dto.signature,
lang: dto.lang,
countryAndTINs: dto.countryAndTINs,