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,