Skip to content
Open
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
35 changes: 35 additions & 0 deletions migration/1761754328324-AddBuyCryptoBuyFiatPlatformFeeAmount.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddBuyCryptoBuyFiatPlatformFeeAmount1761754328324 {
name = 'AddBuyCryptoBuyFiatPlatformFeeAmount1761754328324'

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "wallet" ADD "ownerId" int`);
await queryRunner.query(`ALTER TABLE "buy_fiat" ADD "usedPartnerRef" nvarchar(256)`);
await queryRunner.query(`ALTER TABLE "buy_fiat" ADD "partnerFeeAmount" float`);
await queryRunner.query(`ALTER TABLE "buy_crypto" ADD "usedPartnerRef" nvarchar(256)`);
await queryRunner.query(`ALTER TABLE "buy_crypto" ADD "partnerFeeAmount" float`);
await queryRunner.query(`ALTER TABLE "user" ADD "partnerRefVolume" float NOT NULL CONSTRAINT "DF_1a5ab47a6107199fad3b55afb01" DEFAULT 0`);
await queryRunner.query(`ALTER TABLE "user" ADD "partnerRefCredit" float NOT NULL CONSTRAINT "DF_6ff0d03d287f896b917bb3d70ae" DEFAULT 0`);
await queryRunner.query(`ALTER TABLE "wallet" ADD CONSTRAINT "FK_9bf56f7989a7e5717c92221cce0" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "wallet" DROP CONSTRAINT "FK_9bf56f7989a7e5717c92221cce0"`);
await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "DF_6ff0d03d287f896b917bb3d70ae"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "partnerRefCredit"`);
await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "DF_1a5ab47a6107199fad3b55afb01"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "partnerRefVolume"`);
await queryRunner.query(`ALTER TABLE "buy_crypto" DROP COLUMN "partnerFeeAmount"`);
await queryRunner.query(`ALTER TABLE "buy_crypto" DROP COLUMN "usedPartnerRef"`);
await queryRunner.query(`ALTER TABLE "buy_fiat" DROP COLUMN "partnerFeeAmount"`);
await queryRunner.query(`ALTER TABLE "buy_fiat" DROP COLUMN "usedPartnerRef"`);
await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "ownerId"`);
}
}
32 changes: 32 additions & 0 deletions migration/1773931596651-BankFeeSplit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class BankFeeSplit1773931596651 {
name = 'BankFeeSplit1773931596651'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "buy_fiat" ADD "bankFixedFeeAmount" float`);
await queryRunner.query(`ALTER TABLE "buy_fiat" ADD "bankPercentFeeAmount" float`);
await queryRunner.query(`ALTER TABLE "buy_crypto" ADD "bankFixedFeeAmount" float`);
await queryRunner.query(`ALTER TABLE "buy_crypto" ADD "bankPercentFeeAmount" float`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "buy_crypto" DROP COLUMN "bankPercentFeeAmount"`);
await queryRunner.query(`ALTER TABLE "buy_crypto" DROP COLUMN "bankFixedFeeAmount"`);
await queryRunner.query(`ALTER TABLE "buy_fiat" DROP COLUMN "bankPercentFeeAmount"`);
await queryRunner.query(`ALTER TABLE "buy_fiat" DROP COLUMN "bankFixedFeeAmount"`);
}
}
14 changes: 11 additions & 3 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export class Configuration {
defaultRef = '000-000';
defaultWalletId = 1;
transactionRefundExpirySeconds = 300; // 5 minutes - enough time to fill out the refund form
refRewardManualCheckLimit = 3000; // EUR
txRequestWaitingExpiryDays = 7;
financeLogTotalBalanceChangeLimit = 5000;
faucetAmount = 20; //CHF
Expand Down Expand Up @@ -152,6 +151,7 @@ export class Configuration {
sparkAddressFormat = 'spark1[a-z0-9]{6,250}';
arkAddressFormat = 'ark1[a-z0-9]{6,500}';
firoAddressFormat = 'a[a-zA-HJ-NP-Z0-9]{33}';
firoSparkAddressFormat = 'sm1[a-z0-9]{100,500}';
moneroAddressFormat = '[48][0-9AB][1-9A-HJ-NP-Za-km-z]{93}';
ethereumAddressFormat = '0x\\w{40}';
liquidAddressFormat = '(VTp|VJL)[a-zA-HJ-NP-Z0-9]{77}';
Expand All @@ -165,14 +165,15 @@ export class Configuration {
zanoAddressFormat = 'Z[a-zA-Z0-9]{96}|iZ[a-zA-Z0-9]{106}';
internetComputerPrincipalFormat = '[a-z0-9]{5}(-[a-z0-9]{5})*(-[a-z0-9]{1,5})?';

allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.arkAddressFormat}|${this.firoAddressFormat}|${this.moneroAddressFormat}|${this.ethereumAddressFormat}|${this.liquidAddressFormat}|${this.arweaveAddressFormat}|${this.cardanoAddressFormat}|${this.defichainAddressFormat}|${this.railgunAddressFormat}|${this.solanaAddressFormat}|${this.tronAddressFormat}|${this.zanoAddressFormat}|${this.internetComputerPrincipalFormat}`;
allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.arkAddressFormat}|${this.firoSparkAddressFormat}|${this.firoAddressFormat}|${this.moneroAddressFormat}|${this.ethereumAddressFormat}|${this.liquidAddressFormat}|${this.arweaveAddressFormat}|${this.cardanoAddressFormat}|${this.defichainAddressFormat}|${this.railgunAddressFormat}|${this.solanaAddressFormat}|${this.tronAddressFormat}|${this.zanoAddressFormat}|${this.internetComputerPrincipalFormat}`;

masterKeySignatureFormat = '[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}';
hashSignatureFormat = '[A-Fa-f0-9]{64}';
bitcoinSignatureFormat = '(.{87}=|[A-Za-z0-9+/]+={0,2})';
lightningSignatureFormat = '[a-z0-9]{104}';
lightningCustodialSignatureFormat = '[a-z0-9]{140,146}';
firoSignatureFormat = '(.{87}=|[A-Za-z0-9+/]+={0,2})';
firoSparkSignatureFormat = '[a-f0-9]{260}';
moneroSignatureFormat = 'SigV\\d[0-9a-zA-Z]{88}';
ethereumSignatureFormat = '(0x)?[a-f0-9]{130}';
arweaveSignatureFormat = '[\\w\\-]{683}';
Expand All @@ -183,7 +184,7 @@ export class Configuration {
zanoSignatureFormat = '[a-f0-9]{128}';
internetComputerSignatureFormat = '[a-f0-9]{128,144}';

allSignatureFormat = `${this.masterKeySignatureFormat}|${this.hashSignatureFormat}|${this.bitcoinSignatureFormat}|${this.lightningSignatureFormat}|${this.lightningCustodialSignatureFormat}|${this.firoSignatureFormat}|${this.moneroSignatureFormat}|${this.ethereumSignatureFormat}|${this.arweaveSignatureFormat}|${this.cardanoSignatureFormat}|${this.railgunSignatureFormat}|${this.solanaSignatureFormat}|${this.tronSignatureFormat}|${this.zanoSignatureFormat}|${this.internetComputerSignatureFormat}`;
allSignatureFormat = `${this.masterKeySignatureFormat}|${this.hashSignatureFormat}|${this.bitcoinSignatureFormat}|${this.lightningSignatureFormat}|${this.lightningCustodialSignatureFormat}|${this.firoSignatureFormat}|${this.firoSparkSignatureFormat}|${this.moneroSignatureFormat}|${this.ethereumSignatureFormat}|${this.arweaveSignatureFormat}|${this.cardanoSignatureFormat}|${this.railgunSignatureFormat}|${this.solanaSignatureFormat}|${this.tronSignatureFormat}|${this.zanoSignatureFormat}|${this.internetComputerSignatureFormat}`;

arweaveKeyFormat = '[\\w\\-]{683}';
cardanoKeyFormat = '.*';
Expand Down Expand Up @@ -655,6 +656,8 @@ export class Configuration {

defaultPaymentTimeout: +(process.env.PAYMENT_TIMEOUT ?? 60),
defaultEvmHexPaymentTryCount: +(process.env.PAYMENT_EVM_HEX_TRY_COUNT ?? 15),
defaultTxConfirmationTryCount: +(process.env.PAYMENT_TX_CONFIRMATION_TRY_COUNT ?? 15),
defaultFiroTxIdPaymentTryCount: +(process.env.PAYMENT_FIRO_TX_TRY_COUNT ?? 5),

defaultForexFee: 0.01,
addressForexFee: 0.02,
Expand Down Expand Up @@ -918,6 +921,11 @@ export class Configuration {
password: process.env.FIRO_NODE_PASSWORD,
walletPassword: process.env.FIRO_NODE_WALLET_PASSWORD,
walletAddress: process.env.FIRO_WALLET_ADDRESS,
transparentTxSize: 225, // bytes (Legacy P2PKH, no SegWit, 1-in-1-out)
sparkMintTxSize: 480, // bytes (mintspark with SchnorrProof, 1 recipient)
inputSize: 225, // bytes per input for coin selection fee estimation
outputSize: 34, // bytes per P2PKH output
txOverhead: 10, // bytes fixed transaction overhead
allowUnconfirmedUtxos: process.env.FIRO_ALLOW_UNCONFIRMED_UTXOS === 'true',
cpfpFeeMultiplier: +(process.env.FIRO_CPFP_FEE_MULTIPLIER ?? '2.0'),
defaultFeeMultiplier: +(process.env.FIRO_DEFAULT_FEE_MULTIPLIER ?? '1.5'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
*/

import { Currency } from '@uniswap/sdk-core';
import { HttpService } from 'src/shared/services/http.service';
import { Asset } from 'src/shared/models/asset/asset.entity';
import { HttpService } from 'src/shared/services/http.service';
import { BlockchainTokenBalance } from '../../../shared/dto/blockchain-token-balance.dto';
import { BitcoinRpcClient } from '../rpc/bitcoin-rpc-client';
import { NodeClient, NodeClientConfig } from '../node-client';
import { BitcoinRpcClient } from '../rpc/bitcoin-rpc-client';

// Concrete implementation for testing
class TestNodeClient extends NodeClient {
Expand Down
2 changes: 1 addition & 1 deletion src/integration/blockchain/bitcoin/node/node-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export enum NodeCommand {
}

export abstract class NodeClient extends BlockchainClient {
private readonly logger = new DfxLogger(NodeClient);
protected readonly logger = new DfxLogger(NodeClient);

protected readonly rpc: BitcoinRpcClient;
private readonly queue: QueueHandler;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export interface RawTransactionScriptPubKey {
hex: string;
type: string;
address?: string;
addresses?: string[]; // Firo and older Bitcoin Core forks
}

export interface RawTransactionPrevout {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,26 @@ describe('CryptoService', () => {
expect(CryptoService.getBlockchainsBasedOn('a8MuyHBKL3nYZKAa82x13FxqtExP2sQCqu')).toEqual([Blockchain.FIRO]);
});

it('should return UserAddressType.FIRO for address a8MuyHBKL3nYZKAa82x13FxqtExP2sQCqu', () => {
expect(CryptoService.getAddressType('a8MuyHBKL3nYZKAa82x13FxqtExP2sQCqu')).toEqual(UserAddressType.FIRO);
});

it('should return Blockchain.FIRO for Spark address sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3', () => {
expect(
CryptoService.getBlockchainsBasedOn(
'sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3',
),
).toEqual([Blockchain.FIRO]);
});

it('should return UserAddressType.FIRO_SPARK for Spark address sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3', () => {
expect(
CryptoService.getAddressType(
'sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3',
),
).toEqual(UserAddressType.FIRO_SPARK);
});

it('should return Blockchain.ETHEREUM and Blockchain.BINANCE_SMART_CHAIN for address 0x2d84553B3A4753009A314106d58F0CC21f441234', () => {
expect(CryptoService.getBlockchainsBasedOn('0x2d84553B3A4753009A314106d58F0CC21f441234')).toEqual([
Blockchain.ETHEREUM,
Expand Down
112 changes: 93 additions & 19 deletions src/integration/blockchain/firo/firo-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Config, GetConfig } from 'src/config/config';
import { HttpService } from 'src/shared/services/http.service';
import { BitcoinBasedClient, TestMempoolResult } from '../bitcoin/node/bitcoin-based-client';
import { UTXO } from '../bitcoin/node/dto/bitcoin-transaction.dto';
import { Block, NodeClientConfig } from '../bitcoin/node/node-client';
import { FiroRawTransaction } from './rpc';

Expand All @@ -19,6 +20,8 @@ import { FiroRawTransaction } from './rpc';
* - getrawtransaction: boolean verbose, no multi-level verbosity, no prevout in result
*/
export class FiroClient extends BitcoinBasedClient {
private depositAddressProvider: () => Promise<string[]> = async () => [];

constructor(http: HttpService, url: string) {
const firoConfig = GetConfig().blockchain.firo;

Expand All @@ -40,6 +43,10 @@ export class FiroClient extends BitcoinBasedClient {
return Config.payment.firoAddress;
}

setDepositAddressProvider(provider: () => Promise<string[]>): void {
this.depositAddressProvider = provider;
}

// --- RPC Overrides for Firo compatibility --- //

// Firo's getnewaddress only accepts an optional account parameter, no address type
Expand All @@ -48,14 +55,11 @@ export class FiroClient extends BitcoinBasedClient {
}

// Firo's account-based getbalance with '' returns only the default account, which can be negative.
// Use listunspent filtered to the liquidity and payment addresses for an accurate spendable balance.
// Use listunspent filtered to all non-deposit addresses for an accurate spendable balance.
async getBalance(): Promise<number> {
const utxos = await this.getUtxoForAddresses(
[this.walletAddress, this.paymentAddress],
this.nodeConfig.allowUnconfirmedUtxos,
);
const { utxos } = await this.getNonDepositUtxos();

return this.roundAmount(utxos?.reduce((sum, u) => sum + u.amount, 0) ?? 0);
return this.roundAmount(utxos.reduce((sum, u) => sum + u.amount, 0));
}

// Firo's getblock uses boolean verbose, not int verbosity (0/1/2)
Expand Down Expand Up @@ -94,17 +98,20 @@ export class FiroClient extends BitcoinBasedClient {
vout: number,
feeRate: number,
): Promise<{ outTxId: string; feeAmount: number }> {
const feeAmount = (feeRate * 225) / 1e8;
const feeAmount = (feeRate * Config.blockchain.firo.transparentTxSize) / 1e8;
const sendAmount = this.roundAmount(amount - feeAmount);

const outTxId = await this.buildSignAndBroadcast([{ txid: txId, vout }], { [addressTo]: sendAmount });

return { outTxId, feeAmount };
}

// Delegates to sendManyFromAddress using the liquidity and payment addresses.
// Delegates to sendManyWithUtxos using all non-deposit UTXOs.
async sendMany(payload: { addressTo: string; amount: number }[], feeRate: number): Promise<string> {
return this.sendManyFromAddress([this.walletAddress, this.paymentAddress], payload, feeRate);
const { utxos } = await this.getNonDepositUtxos();
if (!utxos.length) throw new Error('No non-deposit addresses with UTXOs available');

return this.sendManyWithUtxos(utxos, payload, feeRate);
}

// Use UTXOs from the specified addresses to avoid spending deposit UTXOs.
Expand All @@ -114,19 +121,27 @@ export class FiroClient extends BitcoinBasedClient {
payload: { addressTo: string; amount: number }[],
feeRate: number,
): Promise<string> {
const outputs = payload.reduce(
(acc, p) => ({ ...acc, [p.addressTo]: this.roundAmount(p.amount) }),
{} as Record<string, number>,
);
const outputTotal = payload.reduce((sum, p) => sum + p.amount, 0);

const utxos = await this.getUtxoForAddresses(fromAddresses, this.nodeConfig.allowUnconfirmedUtxos);
return this.sendManyWithUtxos(utxos, payload, feeRate);
}

if (!utxos || utxos.length === 0) {
private async sendManyWithUtxos(
utxos: UTXO[],
payload: { addressTo: string; amount: number }[],
feeRate: number,
): Promise<string> {
const outputs: Record<string, number> = {};
for (const p of payload) {
outputs[p.addressTo] = this.roundAmount((outputs[p.addressTo] ?? 0) + p.amount);
}
const outputTotal = payload.reduce((sum, p) => sum + p.amount, 0);

if (utxos.length === 0) {
throw new Error('No UTXOs available on the specified addresses');
}

// Select UTXOs to cover outputs + estimated fee (225 bytes per input, 34 per output, 10 overhead)
// Select UTXOs to cover outputs + estimated fee
const { inputSize, outputSize, txOverhead } = Config.blockchain.firo;
const sortedUtxos = utxos.sort((a, b) => b.amount - a.amount);
const selectedInputs: { txid: string; vout: number }[] = [];
let inputTotal = 0;
Expand All @@ -135,14 +150,14 @@ export class FiroClient extends BitcoinBasedClient {
selectedInputs.push({ txid: utxo.txid, vout: utxo.vout });
inputTotal += utxo.amount;

const estimatedSize = selectedInputs.length * 225 + (payload.length + 1) * 34 + 10;
const estimatedSize = selectedInputs.length * inputSize + (payload.length + 1) * outputSize + txOverhead;
const estimatedFee = (feeRate * estimatedSize) / 1e8;

if (inputTotal >= outputTotal + estimatedFee) break;
}

// Calculate final fee and change
const txSize = selectedInputs.length * 225 + (payload.length + 1) * 34 + 10;
const txSize = selectedInputs.length * inputSize + (payload.length + 1) * outputSize + txOverhead;
const fee = (feeRate * txSize) / 1e8;

if (inputTotal < outputTotal + fee) {
Expand Down Expand Up @@ -204,6 +219,65 @@ export class FiroClient extends BitcoinBasedClient {
}
}

// --- Spark Methods --- //

async verifySparkSignature(address: string, message: string, signature: string): Promise<boolean> {
return this.callNode(() => this.rpc.call<boolean>('verifymessagewithsparkaddress', [address, signature, message]));
}

// Mints transparent FIRO to Spark addresses using all non-deposit UTXOs.
// Change goes to a random wallet address (mintspark limitation), but these stray UTXOs
// are automatically consumed in subsequent mints since we use all non-deposit addresses.
async mintSpark(recipients: { address: string; amount: number }[]): Promise<string> {
const fromAddresses = await this.getNonDepositAddresses();
if (!fromAddresses.length) throw new Error('No non-deposit addresses with UTXOs available');

const sparkAddresses: Record<string, { amount: number; memo: string }> = {};
for (const r of recipients) {
sparkAddresses[r.address] = {
amount: this.roundAmount((sparkAddresses[r.address]?.amount ?? 0) + r.amount),
memo: '',
};
}

const mintTxIds = await this.callNode(
() => this.rpc.call<string[]>('mintspark', [sparkAddresses, false, fromAddresses]),
true,
);

if (!mintTxIds?.length) {
throw new Error('mintspark returned no transaction IDs');
}

if (mintTxIds.length > 1) {
this.logger.warn(`mintspark returned ${mintTxIds.length} TXIDs, only tracking first: ${mintTxIds[0]}`);
}

return mintTxIds[0];
}

private async getNonDepositAddresses(): Promise<string[]> {
const { addresses } = await this.getNonDepositUtxos();
return addresses;
}

private async getNonDepositUtxos(): Promise<{ addresses: string[]; utxos: UTXO[] }> {
const allUtxos = await this.getUtxo(this.nodeConfig.allowUnconfirmedUtxos);
const depositSet = new Set(await this.depositAddressProvider());

const addressSet = new Set<string>();
const utxos: UTXO[] = [];

for (const utxo of allUtxos) {
if (!depositSet.has(utxo.address)) {
addressSet.add(utxo.address);
utxos.push(utxo);
}
}

return { addresses: [...addressSet], utxos };
}

// Firo does not support testmempoolaccept RPC.
// Emulate it using decoderawtransaction + input lookup to calculate fee and size.
// Firo has no SegWit, so size == vsize.
Expand Down
7 changes: 6 additions & 1 deletion src/integration/blockchain/firo/services/firo.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { Config } from 'src/config/config';
import { HttpService } from 'src/shared/services/http.service';
import { Util } from 'src/shared/utils/util';
Expand All @@ -23,4 +23,9 @@ export class FiroService extends BlockchainService {
getPaymentRequest(address: string, amount: number): string {
return `firo:${address}?amount=${Util.numberToFixedString(amount)}`;
}

async verifySparkSignature(message: string, address: string, signature: string): Promise<boolean> {
if (!this.client) throw new ServiceUnavailableException('Firo node not configured');
return this.client.verifySparkSignature(address, message, signature);
}
}
Loading
Loading