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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions migration/1773700000000-AddRailgunAssets.js
Original file line number Diff line number Diff line change
@@ -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'`);
}
}
20 changes: 20 additions & 0 deletions migration/1773700200000-AddFacelessWallet.js
Original file line number Diff line number Diff line change
@@ -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'`);
}
};
7 changes: 7 additions & 0 deletions src/config/__tests__/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
57 changes: 57 additions & 0 deletions src/integration/blockchain/bitcoin/services/bitcoin.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(
Expand Down Expand Up @@ -103,3 +128,7 @@ describe('CryptoService', () => {
function getBlockchain(address: string): Blockchain {
return CryptoService.getDefaultBlockchainBasedOn(address);
}

function getAddressType(address: string): UserAddressType {
return CryptoService.getAddressType(address);
}
Loading
Loading