From b307111e047c2d71fc19bb77d18c82deee5993bf Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:50:44 +0100 Subject: [PATCH 1/5] Add Silent Payment (BIP-352) address support (#3426) * Add Silent Payment (BIP-352) address support - Extend Bitcoin address regex to accept sp1 addresses - Add BITCOIN_SILENT_PAYMENT to UserAddressType enum - Implement verifySilentPayment() using ECDSA recovery against B_spend - Route sp1 addresses to SP verification in verifySignature() - Add unit tests for address detection and signature verification * Fix SP test: use correct bech32m HRP 'sp' instead of 'sp1' The bech32m separator '1' is added automatically by the encoder. HRP 'sp' produces 'sp1...' (correct), HRP 'sp1' would produce 'sp11...' (wrong). Tests now match real BIP-352 address format. * Add SP address to config format validation test Ensures the allAddressFormat regex correctly matches real Silent Payment addresses in the DTO validation layer. * Refactor SP verification: test real service instead of local copy - Make verifySilentPayment() and encodeVarint() static (pure functions) - Replace duplicated logic in tests with CryptoService method call - Fix import ordering (scoped packages before unscoped) * Add SP address tests to bitcoin crypto.service.spec Consistent with the existing pattern of testing getBlockchainsBasedOn and getAddressType for every supported address type. --- src/config/__tests__/config.spec.ts | 7 + src/config/config.ts | 2 +- .../services/__tests__/crypto.service.spec.ts | 16 +++ .../shared/__test__/crypto.service.spec.ts | 29 ++++ .../__test__/verify-silent-payment.spec.ts | 125 ++++++++++++++++++ .../shared/services/crypto.service.ts | 63 ++++++++- .../generic/user/models/user/user.enum.ts | 1 + 7 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts diff --git a/src/config/__tests__/config.spec.ts b/src/config/__tests__/config.spec.ts index ee82ce8814..43ad1d196a 100644 --- a/src/config/__tests__/config.spec.ts +++ b/src/config/__tests__/config.spec.ts @@ -16,6 +16,13 @@ describe('Config', () => { // Taproot expect(addrExp.test('bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297')).toEqual(true); + // Silent Payment (BIP-352) + expect( + addrExp.test( + 'sp1qq09fnmc3dlpkxvz0cder74ys7qnjl6t2k9j936fzqeevc5pgrldp5q7r6f8rx8ppcnuq3gdkqp5qtv8nlgcx7z5mlen2ek57ctjffprec5vyplxz', + ), + ).toEqual(true); + // Lightning expect(addrExp.test('LNURL1DP68GURN8GHJ77P09EMK2MRV944KUMMHDCHKCMN4WFK8QTMDUMFNU2')).toEqual(true); diff --git a/src/config/config.ts b/src/config/config.ts index 9afc458166..39f46326c0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -147,7 +147,7 @@ export class Configuration { github: 'https://github.com/DFXswiss/api#dfx-api', }; - bitcoinAddressFormat = '([13]|bc1)[a-zA-HJ-NP-Z0-9]{25,62}'; + bitcoinAddressFormat = '([13]|bc1)[a-zA-HJ-NP-Z0-9]{25,62}|sp1[a-z0-9]{2,256}'; lightningAddressFormat = '(LNURL|LNDHUB)[A-Z0-9]{25,250}|LNNID[A-Z0-9]{66}'; sparkAddressFormat = 'spark1[a-z0-9]{6,250}'; arkAddressFormat = 'ark1[a-z0-9]{6,500}'; diff --git a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts index b1bfddd6f8..5e61dff8b2 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts @@ -83,6 +83,22 @@ describe('CryptoService', () => { ); }); + it('should return Blockchain.BITCOIN for SP address sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv', () => { + expect( + CryptoService.getBlockchainsBasedOn( + 'sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv', + ), + ).toEqual([Blockchain.BITCOIN]); + }); + + it('should return UserAddressType.BITCOIN_SILENT_PAYMENT for SP address sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv', () => { + expect( + CryptoService.getAddressType( + 'sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv', + ), + ).toEqual(UserAddressType.BITCOIN_SILENT_PAYMENT); + }); + it('should return Blockchain.LIGHTNING for address LNURL1DP68GURN8GHJ7VF3XSEKXC3JX3SK2TNY9EMX7MR5V9NK2CTSWQHXJME0D3H82UNVWQHKZURF9AMRZTMVDE6HYMP0X5LU9EJM', () => { expect( CryptoService.getBlockchainsBasedOn( diff --git a/src/integration/blockchain/shared/__test__/crypto.service.spec.ts b/src/integration/blockchain/shared/__test__/crypto.service.spec.ts index 109c81f8d4..7dbd31d11f 100644 --- a/src/integration/blockchain/shared/__test__/crypto.service.spec.ts +++ b/src/integration/blockchain/shared/__test__/crypto.service.spec.ts @@ -1,5 +1,6 @@ import { Test } from '@nestjs/testing'; import { TestUtil } from 'src/shared/utils/test.util'; +import { UserAddressType } from 'src/subdomains/generic/user/models/user/user.enum'; import { Blockchain } from '../enums/blockchain.enum'; import { CryptoService } from '../services/crypto.service'; @@ -19,6 +20,30 @@ describe('CryptoService', () => { expect(getBlockchain('bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej')).toEqual(Blockchain.BITCOIN); }); + it('should match silent payment addresses as bitcoin', async () => { + expect( + getBlockchain( + 'sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv', + ), + ).toEqual(Blockchain.BITCOIN); + }); + + it('should return BITCOIN_SILENT_PAYMENT address type for sp1 addresses', () => { + expect( + getAddressType( + 'sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv', + ), + ).toEqual(UserAddressType.BITCOIN_SILENT_PAYMENT); + }); + + it('should return BITCOIN_BECH32 for bc1 addresses', () => { + expect(getAddressType('bc1q04fhuhexv662d58y205zhngrkryfpr4lmfxedz')).toEqual(UserAddressType.BITCOIN_BECH32); + }); + + it('should return BITCOIN_LEGACY for legacy addresses', () => { + expect(getAddressType('12uP2ZgBQ7AG56yLdzW4fyyPzELQmitPBB')).toEqual(UserAddressType.BITCOIN_LEGACY); + }); + it('should match lightning addresses', async () => { expect(getBlockchain('LNURL1DP68GURN8GHJ77P09EMK2MRV944KUMMHDCHKCMN4WFK8QTMDUMFNU2')).toEqual(Blockchain.LIGHTNING); expect( @@ -103,3 +128,7 @@ describe('CryptoService', () => { function getBlockchain(address: string): Blockchain { return CryptoService.getDefaultBlockchainBasedOn(address); } + +function getAddressType(address: string): UserAddressType { + return CryptoService.getAddressType(address); +} diff --git a/src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts b/src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts new file mode 100644 index 0000000000..054dbf36f4 --- /dev/null +++ b/src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts @@ -0,0 +1,125 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { bech32m } from 'bech32'; +import { CryptoService } from '../services/crypto.service'; + +/** + * Tests for Silent Payment (BIP-352) signature verification logic. + * + * Simulates the full Cake Wallet → DFX API flow: + * 1. Construct a SP address from known b_scan + b_spend keypairs + * 2. Sign a Bitcoin message with b_spend private key (Cake Wallet side) + * 3. Verify via CryptoService.verifySilentPayment() (DFX API side) + */ +describe('verifySilentPayment', () => { + // Deterministic test keypairs + const bScanPriv = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const bSpendPriv = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; + + const bScanPub = Buffer.from(secp256k1.getPublicKey(bScanPriv, true)); + const bSpendPub = Buffer.from(secp256k1.getPublicKey(bSpendPriv, true)); + + // Construct SP address: version 0 + B_scan (33 bytes) + B_spend (33 bytes) + const spPayload = Buffer.concat([bScanPub, bSpendPub]); + const spWords = [0, ...bech32m.toWords(spPayload)]; + const spAddress = bech32m.encode('sp', spWords, 1023); + + // Access private static method for unit testing + const verifySilentPayment = (message: string, address: string, signature: string): boolean => + (CryptoService as any).verifySilentPayment(message, address, signature); + + // Simulates Cake Wallet signing + function signBitcoinMessage(message: string, privKeyHex: string): string { + const prefix = '\x18Bitcoin Signed Message:\n'; + const msgBytes = Buffer.from(message, 'utf8'); + const varint = encodeVarint(msgBytes.length); + const prefixBytes = Buffer.from(prefix, 'utf8'); + const payload = Buffer.concat([prefixBytes, varint, msgBytes]); + const msgHash = sha256(sha256(payload)); + + const sig = secp256k1.sign(msgHash, privKeyHex); + const recoveryFlag = 31 + sig.recovery; + const sigBuf = Buffer.alloc(65); + sigBuf[0] = recoveryFlag; + Buffer.from(sig.toCompactRawBytes()).copy(sigBuf, 1); + + return sigBuf.toString('base64'); + } + + it('should construct a valid SP address starting with sp1', () => { + expect(spAddress).toMatch(/^sp1/); + }); + + it('should decode SP address back to correct B_spend', () => { + const decoded = bech32m.decode(spAddress, 1023); + const dataBytes = Buffer.from(bech32m.fromWords(decoded.words.slice(1))); + expect(dataBytes.length).toBe(66); + expect(Buffer.from(dataBytes.subarray(33, 66)).equals(bSpendPub)).toBe(true); + }); + + it('should verify a valid signature signed with b_spend', () => { + const message = + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_' + + spAddress; + const signature = signBitcoinMessage(message, bSpendPriv); + + expect(verifySilentPayment(message, spAddress, signature)).toBe(true); + }); + + it('should reject a signature from b_scan (wrong key)', () => { + const message = 'test message'; + const wrongSignature = signBitcoinMessage(message, bScanPriv); + + expect(verifySilentPayment(message, spAddress, wrongSignature)).toBe(false); + }); + + it('should reject a signature for a different message', () => { + const signature = signBitcoinMessage('original message', bSpendPriv); + + expect(verifySilentPayment('different message', spAddress, signature)).toBe(false); + }); + + it('should reject a signature from a completely different key', () => { + const otherPrivValid = '1111111111111111111111111111111111111111111111111111111111111111'; + const message = 'test'; + const wrongSig = signBitcoinMessage(message, otherPrivValid); + + expect(verifySilentPayment(message, spAddress, wrongSig)).toBe(false); + }); + + it('should return false for invalid signature length', () => { + expect(verifySilentPayment('test', spAddress, Buffer.alloc(32).toString('base64'))).toBe(false); + }); + + it('should return false for invalid base64 signature', () => { + expect(verifySilentPayment('test', spAddress, 'not-valid!!')).toBe(false); + }); + + it('should verify multiple different messages independently', () => { + const msg1 = 'first message'; + const msg2 = 'second message'; + const sig1 = signBitcoinMessage(msg1, bSpendPriv); + const sig2 = signBitcoinMessage(msg2, bSpendPriv); + + expect(verifySilentPayment(msg1, spAddress, sig1)).toBe(true); + expect(verifySilentPayment(msg2, spAddress, sig2)).toBe(true); + + // Cross-verify should fail + expect(verifySilentPayment(msg1, spAddress, sig2)).toBe(false); + expect(verifySilentPayment(msg2, spAddress, sig1)).toBe(false); + }); +}); + +function encodeVarint(n: number): Buffer { + if (n < 0xfd) return Buffer.from([n]); + if (n <= 0xffff) { + const buf = Buffer.alloc(3); + buf[0] = 0xfd; + buf.writeUInt16LE(n, 1); + return buf; + } + const buf = Buffer.alloc(5); + buf[0] = 0xfe; + buf.writeUInt32LE(n, 1); + return buf; +} diff --git a/src/integration/blockchain/shared/services/crypto.service.ts b/src/integration/blockchain/shared/services/crypto.service.ts index 584881e19d..b6b64f64ec 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -1,4 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { bech32m } from 'bech32'; import { Verifier } from 'bip322-js'; import { verify } from 'bitcoinjs-message'; import { isEthereumAddress } from 'class-validator'; @@ -118,6 +121,7 @@ export class CryptoService { switch (blockchain) { case Blockchain.BITCOIN: + if (address.startsWith('sp1')) return UserAddressType.BITCOIN_SILENT_PAYMENT; if (address.startsWith('bc1')) return UserAddressType.BITCOIN_BECH32; return UserAddressType.BITCOIN_LEGACY; @@ -289,7 +293,10 @@ export class CryptoService { try { if (EvmBlockchains.includes(detectedBlockchain)) return await this.verifyEthereumBased(message, address, signature, blockchain ?? detectedBlockchain); - if (detectedBlockchain === Blockchain.BITCOIN) return this.verifyBitcoinBased(message, address, signature, null); + if (detectedBlockchain === Blockchain.BITCOIN) { + if (address.startsWith('sp1')) return CryptoService.verifySilentPayment(message, address, signature); + return this.verifyBitcoinBased(message, address, signature, null); + } if (detectedBlockchain === Blockchain.LIGHTNING) return await this.verifyLightning(address, message, signature); if (detectedBlockchain === Blockchain.SPARK) return await this.verifySpark(message, address, signature); if (detectedBlockchain === Blockchain.ARK) return await this.verifyArk(message, address, signature); @@ -428,4 +435,58 @@ export class CryptoService { private async verifyRailgun(message: string, address: string, signature: string): Promise { return this.railgunService.verifySignature(message, address, signature); } + + private static verifySilentPayment(message: string, address: string, signature: string): boolean { + try { + // 1. Decode SP address (bech32m) to extract B_spend public key + const decoded = bech32m.decode(address, 1023); + const dataBytes = Buffer.from(bech32m.fromWords(decoded.words.slice(1))); + // SP address payload: 33 bytes B_scan + 33 bytes B_spend + if (dataBytes.length !== 66) return false; + const bSpend = dataBytes.subarray(33, 66); + + // 2. Compute Bitcoin Message hash: double-SHA256(prefix + varint(len) + message) + const prefix = '\x18Bitcoin Signed Message:\n'; + const msgBytes = Buffer.from(message, 'utf8'); + const varint = CryptoService.encodeVarint(msgBytes.length); + const prefixBytes = Buffer.from(prefix, 'utf8'); + const payload = Buffer.concat([prefixBytes, varint, msgBytes]); + const msgHash = sha256(sha256(payload)); + + // 3. Decode signature: base64 -> 65 bytes (recoveryId + r + s) + const sigBuf = Buffer.from(signature, 'base64'); + if (sigBuf.length !== 65) return false; + const recoveryFlag = sigBuf[0]; + // Bitcoin signed message recovery: flag 27-30 = uncompressed, 31-34 = compressed + const recoveryId = (recoveryFlag >= 31 ? recoveryFlag - 31 : recoveryFlag - 27) & 3; + const r = sigBuf.subarray(1, 33); + const s = sigBuf.subarray(33, 65); + const sig = new secp256k1.Signature( + BigInt('0x' + Buffer.from(r).toString('hex')), + BigInt('0x' + Buffer.from(s).toString('hex')), + ).addRecoveryBit(recoveryId); + + // 4. Recover public key and compare to B_spend + const recoveredPoint = sig.recoverPublicKey(msgHash); + const recoveredBytes = recoveredPoint.toRawBytes(true); // compressed + + return Buffer.from(recoveredBytes).equals(Buffer.from(bSpend)); + } catch { + return false; + } + } + + private static encodeVarint(n: number): Buffer { + if (n < 0xfd) return Buffer.from([n]); + if (n <= 0xffff) { + const buf = Buffer.alloc(3); + buf[0] = 0xfd; + buf.writeUInt16LE(n, 1); + return buf; + } + const buf = Buffer.alloc(5); + buf[0] = 0xfe; + buf.writeUInt32LE(n, 1); + return buf; + } } diff --git a/src/subdomains/generic/user/models/user/user.enum.ts b/src/subdomains/generic/user/models/user/user.enum.ts index 7abf9c4840..6dd37bff27 100644 --- a/src/subdomains/generic/user/models/user/user.enum.ts +++ b/src/subdomains/generic/user/models/user/user.enum.ts @@ -8,6 +8,7 @@ export enum UserStatus { export enum UserAddressType { BITCOIN_LEGACY = 'BitcoinLegacy', BITCOIN_BECH32 = 'BitcoinBech32', + BITCOIN_SILENT_PAYMENT = 'BitcoinSilentPayment', EVM = 'EVM', LN_URL = 'LNURL', LN_NID = 'LNNID', From 5b4213303e83792485ab974bf5c278a8619253f0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:15:33 +0100 Subject: [PATCH 2/5] Add Railgun blockchain assets and base fees (#3429) * feat: add Railgun blockchain assets and base fees Add 6 Railgun-shielded ERC-20 assets (WETH, USDT, dEURO, ZCHF, DAI, WBTC) with matching base fee entries for buy/sell transactions. * fix: set Railgun assets to not buyable/sellable and extend base fee IDs - Set buyable=0, sellable=0 for all Railgun assets (no payout infra yet) - Add fee IDs 7, 8, 19, 26, 27, 28 (Tier2-Personal/Business/SoleProprietorship for all payment directions) to prevent "Base fee is missing" errors * fix: set Railgun assets to buyable=1 (OnRamp), sellable=0 (no OffRamp yet) * fix: revert fee IDs to match production DB structure Production DB shows IDs 7,8 are Tier1 (short lists), 19-24 don't exist, and 25-28 are Talium-specific. Only IDs 4-6, 10-12, 16-18 are Tier2 base fees with explicit asset lists that need new assets appended. --- migration/1773700000000-AddRailgunAssets.js | 135 ++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 migration/1773700000000-AddRailgunAssets.js diff --git a/migration/1773700000000-AddRailgunAssets.js b/migration/1773700000000-AddRailgunAssets.js new file mode 100644 index 0000000000..d2b6c68703 --- /dev/null +++ b/migration/1773700000000-AddRailgunAssets.js @@ -0,0 +1,135 @@ +module.exports = class AddRailgunAssets1773700000000 { + name = 'AddRailgunAssets1773700000000' + + async up(queryRunner) { + // Railgun/WETH — based on Ethereum/WETH (priceRule 6) + await queryRunner.query(` + IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Railgun/WETH') + INSERT INTO "dbo"."asset" ( + "name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description", + "comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId", + "approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder" + ) VALUES ( + 'WETH', 'Token', 1, 0, '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', 'WETH', 'Public', 'Railgun', 'Railgun/WETH', 'Wrapped Ether', + 0, 18, 0, 1, 0, 0, 0, 0, + 'Other', 0, 0, 0, 0, 6, + NULL, NULL, NULL, NULL + ) + `); + + // Railgun/USDT — based on Ethereum/USDT (priceRule 40) + await queryRunner.query(` + IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Railgun/USDT') + INSERT INTO "dbo"."asset" ( + "name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description", + "comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId", + "approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder" + ) VALUES ( + 'USDT', 'Token', 1, 0, '0xdac17f958d2ee523a2206206994597c13d831ec7', 'USDT', 'Public', 'Railgun', 'Railgun/USDT', 'Tether', + 0, 6, 0, 1, 0, 0, 0, 0, + 'USD', 0, 0, 0, 0, 40, + NULL, NULL, NULL, NULL + ) + `); + + // Railgun/dEURO — based on Ethereum/dEURO (priceRule 39) + await queryRunner.query(` + IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Railgun/dEURO') + INSERT INTO "dbo"."asset" ( + "name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description", + "comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId", + "approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder" + ) VALUES ( + 'dEURO', 'Token', 1, 0, '0xba3f535bbcccca2a154b573ca6c5a49baae0a3ea', 'dEURO', 'Public', 'Railgun', 'Railgun/dEURO', 'Decentralized EURO', + 0, 18, 0, 1, 0, 0, 0, 0, + 'EUR', 0, 0, 0, 0, 39, + NULL, NULL, NULL, NULL + ) + `); + + // Railgun/ZCHF — based on Ethereum/ZCHF (priceRule 2, amlRuleFrom 8) + await queryRunner.query(` + IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Railgun/ZCHF') + INSERT INTO "dbo"."asset" ( + "name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description", + "comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId", + "approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder" + ) VALUES ( + 'ZCHF', 'Token', 1, 0, '0xb58e61c3098d85632df34eecfb899a1ed80921cb', 'ZCHF', 'Public', 'Railgun', 'Railgun/ZCHF', 'Frankencoin', + 0, 18, 0, 1, 0, 0, 0, 0, + 'CHF', 0, 0, 8, 0, 2, + NULL, NULL, NULL, NULL + ) + `); + + // Railgun/DAI — based on Ethereum/DAI (priceRule 4) + await queryRunner.query(` + IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Railgun/DAI') + INSERT INTO "dbo"."asset" ( + "name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description", + "comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId", + "approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder" + ) VALUES ( + 'DAI', 'Token', 1, 0, '0x6b175474e89094c44da98b954eedeac495271d0f', 'DAI', 'Public', 'Railgun', 'Railgun/DAI', 'Dai', + 0, 18, 0, 1, 0, 0, 0, 0, + 'USD', 0, 0, 0, 0, 4, + NULL, NULL, NULL, NULL + ) + `); + + // Railgun/WBTC — based on Ethereum/WBTC (priceRule 34) + await queryRunner.query(` + IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Railgun/WBTC') + INSERT INTO "dbo"."asset" ( + "name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description", + "comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId", + "approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder" + ) VALUES ( + 'WBTC', 'Token', 1, 0, '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', 'WBTC', 'Public', 'Railgun', 'Railgun/WBTC', 'Wrapped BTC', + 0, 8, 0, 1, 0, 0, 0, 0, + 'BTC', 0, 0, 0, 0, 34, + NULL, NULL, NULL, NULL + ) + `); + // --- Add all Railgun assets to base fees --- + // Tier2 base fees with explicit asset lists (BuyCrypto, BuyFiat, CryptoCrypto) + const feeIds = [4, 5, 6, 10, 11, 12, 16, 17, 18]; + const railgunAssets = await queryRunner.query( + `SELECT "id" FROM "dbo"."asset" WHERE "blockchain" = 'Railgun'`, + ); + + for (const { id: assetId } of railgunAssets) { + await queryRunner.query(` + UPDATE "dbo"."fee" + SET "assets" = "assets" + ';${assetId}' + WHERE "id" IN (${feeIds.join(', ')}) + AND ';' + "assets" + ';' NOT LIKE '%;${assetId};%' + `); + } + } + + async down(queryRunner) { + // Remove from base fees first + // Tier2 base fees with explicit asset lists (BuyCrypto, BuyFiat, CryptoCrypto) + const feeIds = [4, 5, 6, 10, 11, 12, 16, 17, 18]; + const railgunAssets = await queryRunner.query( + `SELECT "id" FROM "dbo"."asset" WHERE "blockchain" = 'Railgun'`, + ); + + for (const { id: assetId } of railgunAssets) { + await queryRunner.query(` + UPDATE "dbo"."fee" + SET "assets" = REPLACE("assets", ';${assetId}', '') + WHERE "id" IN (${feeIds.join(', ')}) + `); + } + + await queryRunner.query(`DELETE FROM "dbo"."asset" WHERE "blockchain" = 'Railgun'`); + } +} From ecd91a2e05173e2eb5e938c157c5a82928c514ce Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:19:34 +0100 Subject: [PATCH 3/5] feat: add Faceless wallet entry (#3431) --- migration/1773700200000-AddFacelessWallet.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 migration/1773700200000-AddFacelessWallet.js diff --git a/migration/1773700200000-AddFacelessWallet.js b/migration/1773700200000-AddFacelessWallet.js new file mode 100644 index 0000000000..d4319823d3 --- /dev/null +++ b/migration/1773700200000-AddFacelessWallet.js @@ -0,0 +1,20 @@ +module.exports = class AddFacelessWallet1773700200000 { + name = 'AddFacelessWallet1773700200000'; + + async up(queryRunner) { + await queryRunner.query(` + IF NOT EXISTS (SELECT 1 FROM "dbo"."wallet" WHERE "name" = 'Faceless') + INSERT INTO "dbo"."wallet" ( + "name", "isKycClient", "amlRules", "autoTradeApproval", + "mailConfig", "usesDummyAddresses", "displayFraudWarning", "buySpecificIbanEnabled" + ) VALUES ( + 'Faceless', 0, '0', 1, + 'BuyCrypto;BuyFiat;RefReward;Info', 0, 0, 0 + ) + `); + } + + async down(queryRunner) { + await queryRunner.query(`DELETE FROM "dbo"."wallet" WHERE "name" = 'Faceless'`); + } +}; From fa2b661c270383b7e645d6e25280d54c864ba83d Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:07:04 +0100 Subject: [PATCH 4/5] [NOTASK] auto refund refactoring (#3435) --- .../process/services/buy-crypto-preparation.service.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index b8cb72f540..5d52682a97 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -473,10 +473,14 @@ export class BuyCryptoPreparationService { }; const entities = await this.buyCryptoRepo.find({ where: [ - // Bank refund: requires creditorData for FiatOutput - { ...baseRequest, chargebackIban: Not(IsNull()), chargebackCreditorData: Not(IsNull()) }, - // Checkout refund: no creditorData needed + { + ...baseRequest, + bankTx: { id: Not(IsNull()) }, + chargebackIban: Not(IsNull()), + chargebackCreditorData: Not(IsNull()), + }, { ...baseRequest, checkoutTx: { id: Not(IsNull()) } }, + { ...baseRequest, cryptoInput: { id: Not(IsNull()) }, chargebackIban: Not(IsNull()) }, ], relations: { checkoutTx: true, bankTx: true, cryptoInput: true, transaction: { userData: true } }, }); From 129ed1eb4ff5ad2c03befe7998ca2989aec53a5e Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:22:04 +0100 Subject: [PATCH 5/5] Chore: various improvements (#3437) * fix: railgun asset migration * chore: refactored SP signature verification * fix: JUSD price observer * fix: Spark token optimization * fix: test * fix: tests 2 * fix: tests 3 --- migration/1773700000000-AddRailgunAssets.js | 6 +- .../bitcoin/services/bitcoin.service.ts | 57 +++++++++++++++++ .../__test__/verify-silent-payment.spec.ts | 7 +-- .../shared/services/crypto.service.ts | 62 +------------------ .../blockchain/spark/spark-client.ts | 18 +++++- .../monitoring/observers/exchange.observer.ts | 19 ++++-- 6 files changed, 95 insertions(+), 74 deletions(-) diff --git a/migration/1773700000000-AddRailgunAssets.js b/migration/1773700000000-AddRailgunAssets.js index d2b6c68703..5bc0c5901e 100644 --- a/migration/1773700000000-AddRailgunAssets.js +++ b/migration/1773700000000-AddRailgunAssets.js @@ -98,8 +98,7 @@ module.exports = class AddRailgunAssets1773700000000 { ) `); // --- Add all Railgun assets to base fees --- - // Tier2 base fees with explicit asset lists (BuyCrypto, BuyFiat, CryptoCrypto) - const feeIds = [4, 5, 6, 10, 11, 12, 16, 17, 18]; + const feeIds = [7, 8, 10, 17, 18, 19, 26, 27, 28]; const railgunAssets = await queryRunner.query( `SELECT "id" FROM "dbo"."asset" WHERE "blockchain" = 'Railgun'`, ); @@ -116,8 +115,7 @@ module.exports = class AddRailgunAssets1773700000000 { async down(queryRunner) { // Remove from base fees first - // Tier2 base fees with explicit asset lists (BuyCrypto, BuyFiat, CryptoCrypto) - const feeIds = [4, 5, 6, 10, 11, 12, 16, 17, 18]; + const feeIds = [7, 8, 10, 17, 18, 19, 26, 27, 28]; const railgunAssets = await queryRunner.query( `SELECT "id" FROM "dbo"."asset" WHERE "blockchain" = 'Railgun'`, ); diff --git a/src/integration/blockchain/bitcoin/services/bitcoin.service.ts b/src/integration/blockchain/bitcoin/services/bitcoin.service.ts index bc410d6f75..c5232553f3 100644 --- a/src/integration/blockchain/bitcoin/services/bitcoin.service.ts +++ b/src/integration/blockchain/bitcoin/services/bitcoin.service.ts @@ -1,4 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { bech32m } from 'bech32'; import { Config } from 'src/config/config'; import { HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; @@ -59,6 +62,60 @@ export class BitcoinService extends BlockchainService { return `bitcoin:${address}?amount=${Util.numberToFixedString(amount)}&label=${label}`; } + static verifySilentPaymentSignature(message: string, address: string, signature: string): boolean { + try { + // 1. Decode SP address (bech32m) to extract B_spend public key + const decoded = bech32m.decode(address, 1023); + const dataBytes = Buffer.from(bech32m.fromWords(decoded.words.slice(1))); + // SP address payload: 33 bytes B_scan + 33 bytes B_spend + if (dataBytes.length !== 66) return false; + const bSpend = dataBytes.subarray(33, 66); + + // 2. Compute Bitcoin Message hash: double-SHA256(prefix + varint(len) + message) + const prefix = '\x18Bitcoin Signed Message:\n'; + const msgBytes = Buffer.from(message, 'utf8'); + const varint = BitcoinService.encodeVarint(msgBytes.length); + const prefixBytes = Buffer.from(prefix, 'utf8'); + const payload = Buffer.concat([prefixBytes, varint, msgBytes]); + const msgHash = sha256(sha256(payload)); + + // 3. Decode signature: base64 -> 65 bytes (recoveryId + r + s) + const sigBuf = Buffer.from(signature, 'base64'); + if (sigBuf.length !== 65) return false; + const recoveryFlag = sigBuf[0]; + // Bitcoin signed message recovery: flag 27-30 = uncompressed, 31-34 = compressed + const recoveryId = (recoveryFlag >= 31 ? recoveryFlag - 31 : recoveryFlag - 27) & 3; + const r = sigBuf.subarray(1, 33); + const s = sigBuf.subarray(33, 65); + const sig = new secp256k1.Signature( + BigInt('0x' + Buffer.from(r).toString('hex')), + BigInt('0x' + Buffer.from(s).toString('hex')), + ).addRecoveryBit(recoveryId); + + // 4. Recover public key and compare to B_spend + const recoveredPoint = sig.recoverPublicKey(msgHash); + const recoveredBytes = recoveredPoint.toRawBytes(true); // compressed + + return Buffer.from(recoveredBytes).equals(Buffer.from(bSpend)); + } catch { + return false; + } + } + + private static encodeVarint(n: number): Buffer { + if (n < 0xfd) return Buffer.from([n]); + if (n <= 0xffff) { + const buf = Buffer.alloc(3); + buf[0] = 0xfd; + buf.writeUInt16LE(n, 1); + return buf; + } + const buf = Buffer.alloc(5); + buf[0] = 0xfe; + buf.writeUInt32LE(n, 1); + return buf; + } + // --- INIT METHODS --- // private initAllNodes(): void { diff --git a/src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts b/src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts index 054dbf36f4..411c378cc1 100644 --- a/src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts +++ b/src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts @@ -1,7 +1,7 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; import { bech32m } from 'bech32'; -import { CryptoService } from '../services/crypto.service'; +import { BitcoinService } from '../../bitcoin/services/bitcoin.service'; /** * Tests for Silent Payment (BIP-352) signature verification logic. @@ -9,7 +9,7 @@ import { CryptoService } from '../services/crypto.service'; * Simulates the full Cake Wallet → DFX API flow: * 1. Construct a SP address from known b_scan + b_spend keypairs * 2. Sign a Bitcoin message with b_spend private key (Cake Wallet side) - * 3. Verify via CryptoService.verifySilentPayment() (DFX API side) + * 3. Verify via BitcoinService.verifySilentPaymentSignature() (DFX API side) */ describe('verifySilentPayment', () => { // Deterministic test keypairs @@ -24,9 +24,8 @@ describe('verifySilentPayment', () => { const spWords = [0, ...bech32m.toWords(spPayload)]; const spAddress = bech32m.encode('sp', spWords, 1023); - // Access private static method for unit testing const verifySilentPayment = (message: string, address: string, signature: string): boolean => - (CryptoService as any).verifySilentPayment(message, address, signature); + BitcoinService.verifySilentPaymentSignature(message, address, signature); // Simulates Cake Wallet signing function signBitcoinMessage(message: string, privKeyHex: string): string { diff --git a/src/integration/blockchain/shared/services/crypto.service.ts b/src/integration/blockchain/shared/services/crypto.service.ts index b6b64f64ec..a2d5971487 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -1,7 +1,4 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { secp256k1 } from '@noble/curves/secp256k1'; -import { sha256 } from '@noble/hashes/sha256'; -import { bech32m } from 'bech32'; import { Verifier } from 'bip322-js'; import { verify } from 'bitcoinjs-message'; import { isEthereumAddress } from 'class-validator'; @@ -12,6 +9,7 @@ import { LightningService } from 'src/integration/lightning/services/lightning.s import { RailgunService } from 'src/integration/railgun/railgun.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { UserAddressType } from 'src/subdomains/generic/user/models/user/user.enum'; +import { ArkService } from '../../ark/ark.service'; import { ArweaveService } from '../../arweave/services/arweave.service'; import { BitcoinService } from '../../bitcoin/services/bitcoin.service'; import { CardanoService } from '../../cardano/services/cardano.service'; @@ -20,7 +18,6 @@ import { InternetComputerService } from '../../icp/services/icp.service'; import { LiquidHelper } from '../../liquid/liquid-helper'; import { MoneroService } from '../../monero/services/monero.service'; import { SolanaService } from '../../solana/services/solana.service'; -import { ArkService } from '../../ark/ark.service'; import { SparkService } from '../../spark/spark.service'; import { TronService } from '../../tron/services/tron.service'; import { ZanoService } from '../../zano/services/zano.service'; @@ -294,7 +291,8 @@ export class CryptoService { if (EvmBlockchains.includes(detectedBlockchain)) return await this.verifyEthereumBased(message, address, signature, blockchain ?? detectedBlockchain); if (detectedBlockchain === Blockchain.BITCOIN) { - if (address.startsWith('sp1')) return CryptoService.verifySilentPayment(message, address, signature); + if (CryptoService.getAddressType(address) === UserAddressType.BITCOIN_SILENT_PAYMENT) + return BitcoinService.verifySilentPaymentSignature(message, address, signature); return this.verifyBitcoinBased(message, address, signature, null); } if (detectedBlockchain === Blockchain.LIGHTNING) return await this.verifyLightning(address, message, signature); @@ -435,58 +433,4 @@ export class CryptoService { private async verifyRailgun(message: string, address: string, signature: string): Promise { return this.railgunService.verifySignature(message, address, signature); } - - private static verifySilentPayment(message: string, address: string, signature: string): boolean { - try { - // 1. Decode SP address (bech32m) to extract B_spend public key - const decoded = bech32m.decode(address, 1023); - const dataBytes = Buffer.from(bech32m.fromWords(decoded.words.slice(1))); - // SP address payload: 33 bytes B_scan + 33 bytes B_spend - if (dataBytes.length !== 66) return false; - const bSpend = dataBytes.subarray(33, 66); - - // 2. Compute Bitcoin Message hash: double-SHA256(prefix + varint(len) + message) - const prefix = '\x18Bitcoin Signed Message:\n'; - const msgBytes = Buffer.from(message, 'utf8'); - const varint = CryptoService.encodeVarint(msgBytes.length); - const prefixBytes = Buffer.from(prefix, 'utf8'); - const payload = Buffer.concat([prefixBytes, varint, msgBytes]); - const msgHash = sha256(sha256(payload)); - - // 3. Decode signature: base64 -> 65 bytes (recoveryId + r + s) - const sigBuf = Buffer.from(signature, 'base64'); - if (sigBuf.length !== 65) return false; - const recoveryFlag = sigBuf[0]; - // Bitcoin signed message recovery: flag 27-30 = uncompressed, 31-34 = compressed - const recoveryId = (recoveryFlag >= 31 ? recoveryFlag - 31 : recoveryFlag - 27) & 3; - const r = sigBuf.subarray(1, 33); - const s = sigBuf.subarray(33, 65); - const sig = new secp256k1.Signature( - BigInt('0x' + Buffer.from(r).toString('hex')), - BigInt('0x' + Buffer.from(s).toString('hex')), - ).addRecoveryBit(recoveryId); - - // 4. Recover public key and compare to B_spend - const recoveredPoint = sig.recoverPublicKey(msgHash); - const recoveredBytes = recoveredPoint.toRawBytes(true); // compressed - - return Buffer.from(recoveredBytes).equals(Buffer.from(bSpend)); - } catch { - return false; - } - } - - private static encodeVarint(n: number): Buffer { - if (n < 0xfd) return Buffer.from([n]); - if (n <= 0xffff) { - const buf = Buffer.alloc(3); - buf[0] = 0xfd; - buf.writeUInt16LE(n, 1); - return buf; - } - const buf = Buffer.alloc(5); - buf[0] = 0xfe; - buf.writeUInt32LE(n, 1); - return buf; - } } diff --git a/src/integration/blockchain/spark/spark-client.ts b/src/integration/blockchain/spark/spark-client.ts index 0c96fbfebc..15a849dea4 100644 --- a/src/integration/blockchain/spark/spark-client.ts +++ b/src/integration/blockchain/spark/spark-client.ts @@ -49,12 +49,14 @@ export class SparkClient extends BlockchainClient { private wallet: AsyncField; private readonly cachedAddress: AsyncField; private reconnectAttempt = 0; + private tokenOptimizationInterval?: NodeJS.Timeout; constructor() { super(); this.wallet = new AsyncField(() => this.initializeWallet(), true); this.cachedAddress = new AsyncField(() => this.wallet.then((w) => w.getSparkAddress()), true); + this.startTokenOptimization(); } private async call(operation: (wallet: SparkWallet) => Promise): Promise { @@ -150,13 +152,27 @@ export class SparkClient extends BlockchainClient { return SparkWallet.initialize({ mnemonicOrSeed: GetConfig().blockchain.spark.sparkWalletSeed, accountNumber: 0, - options: { network: 'MAINNET' }, + options: { + network: 'MAINNET', + tokenOptimizationOptions: { enabled: false }, + }, }).then(({ wallet }) => { wallet.on('stream:disconnected', () => this.reconnectWallet()); return this.syncLeaves(wallet); }); } + private startTokenOptimization(): void { + if (this.tokenOptimizationInterval) clearInterval(this.tokenOptimizationInterval); + + const intervalMs = 5 * 60 * 1000; // 5 minutes + this.tokenOptimizationInterval = setInterval(() => { + this.call((wallet) => wallet.optimizeTokenOutputs()).catch((e) => { + this.logger.warn('Token optimization failed, will retry on next interval:', e); + }); + }, intervalMs); + } + private reconnectWallet(): void { const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 60_000); this.reconnectAttempt++; diff --git a/src/subdomains/core/monitoring/observers/exchange.observer.ts b/src/subdomains/core/monitoring/observers/exchange.observer.ts index b9453f6e67..e33abd6d04 100644 --- a/src/subdomains/core/monitoring/observers/exchange.observer.ts +++ b/src/subdomains/core/monitoring/observers/exchange.observer.ts @@ -59,11 +59,18 @@ export class ExchangeObserver extends MetricObserver { // *** HELPER METHODS *** // private async getXtPriceDeviation(): Promise { - const usdt = await this.assetService.getAssetByQuery({ - name: 'USDT', - blockchain: Blockchain.ETHEREUM, - type: AssetType.TOKEN, - }); + const [usdt, jusd] = await Promise.all([ + this.assetService.getAssetByQuery({ + name: 'USDT', + blockchain: Blockchain.ETHEREUM, + type: AssetType.TOKEN, + }), + await this.assetService.getAssetByQuery({ + name: 'JUSD', + blockchain: Blockchain.CITREA, + type: AssetType.TOKEN, + }), + ]); const xtDeurUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'USDT', 'DEURO'); const xtDepsUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'USDT', 'DEPS'); @@ -75,7 +82,7 @@ export class ExchangeObserver extends MetricObserver { PriceValidity.VALID_ONLY, ); const referenceDepsUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.DEURO, 'USDT', 'DEPS'); - const referenceJusdUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.JUICE, 'USDT', 'JUSD'); + const referenceJusdUsdtPrice = await this.pricingService.getPrice(usdt, jusd, PriceValidity.VALID_ONLY); return [ {