diff --git a/migration/1773700000000-AddRailgunAssets.js b/migration/1773700000000-AddRailgunAssets.js new file mode 100644 index 0000000000..5bc0c5901e --- /dev/null +++ b/migration/1773700000000-AddRailgunAssets.js @@ -0,0 +1,133 @@ +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 --- + const feeIds = [7, 8, 10, 17, 18, 19, 26, 27, 28]; + 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 + const feeIds = [7, 8, 10, 17, 18, 19, 26, 27, 28]; + 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'`); + } +} 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'`); + } +}; 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/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__/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..411c378cc1 --- /dev/null +++ b/src/integration/blockchain/shared/__test__/verify-silent-payment.spec.ts @@ -0,0 +1,124 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { bech32m } from 'bech32'; +import { BitcoinService } from '../../bitcoin/services/bitcoin.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 BitcoinService.verifySilentPaymentSignature() (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); + + const verifySilentPayment = (message: string, address: string, signature: string): boolean => + BitcoinService.verifySilentPaymentSignature(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..a2d5971487 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -9,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'; @@ -17,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'; @@ -118,6 +118,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 +290,11 @@ 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 (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); if (detectedBlockchain === Blockchain.SPARK) return await this.verifySpark(message, address, signature); if (detectedBlockchain === Blockchain.ARK) return await this.verifyArk(message, address, signature); 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/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 } }, }); 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 [ { 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',