From 63a0f0c65c54a3ee639c329afbe8695c29cd697a Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Sat, 30 May 2026 14:17:18 +0100 Subject: [PATCH 1/4] refactor: remove firo-rpc package, use firo-electrumx exclusively --- .changeset/five-hands-argue.md | 5 + .changeset/metal-drums-wink.md | 5 + .../{firo-rpc => firo-electrumx}/README.md | 10 +- .../lib/firoElectrumxNetwork.ts | 841 +++++++++++++++ packages/networks/firo-electrumx/lib/index.ts | 1 + .../{firo-rpc => firo-electrumx}/package.json | 8 +- .../tests/firoElectrumxNetwork.spec.ts | 724 +++++++++++++ .../tests/mocked/electrumxSocket.mock.ts | 98 ++ .../networks/firo-electrumx/tests/testData.ts | 101 ++ .../tsconfig.build.json | 0 .../tsconfig.json | 0 .../vitest.config.ts | 0 .../networks/firo-rpc/lib/firoRpcNetwork.ts | 878 ---------------- packages/networks/firo-rpc/lib/index.ts | 4 - packages/networks/firo-rpc/lib/types.ts | 102 -- .../firo-rpc/tests/firoRpcNetwork.spec.ts | 982 ------------------ .../tests/mocked/rateLimitedAxios.mock.ts | 17 - packages/networks/firo-rpc/tests/testData.ts | 329 ------ services/guard-service/config/default.yaml | 14 +- services/guard-service/config/test.yaml | 8 +- .../docker/custom-environment-variables.yaml | 8 +- services/guard-service/package.json | 2 +- .../src/configs/guardsFiroConfigs.ts | 28 +- .../src/handlers/chainHandler.ts | 22 +- services/guard-service/tsconfig.json | 2 +- 25 files changed, 1815 insertions(+), 2374 deletions(-) create mode 100644 .changeset/five-hands-argue.md create mode 100644 .changeset/metal-drums-wink.md rename packages/networks/{firo-rpc => firo-electrumx}/README.md (55%) create mode 100644 packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts create mode 100644 packages/networks/firo-electrumx/lib/index.ts rename packages/networks/{firo-rpc => firo-electrumx}/package.json (85%) create mode 100644 packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts create mode 100644 packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts create mode 100644 packages/networks/firo-electrumx/tests/testData.ts rename packages/networks/{firo-rpc => firo-electrumx}/tsconfig.build.json (100%) rename packages/networks/{firo-rpc => firo-electrumx}/tsconfig.json (100%) rename packages/networks/{firo-rpc => firo-electrumx}/vitest.config.ts (100%) delete mode 100644 packages/networks/firo-rpc/lib/firoRpcNetwork.ts delete mode 100644 packages/networks/firo-rpc/lib/index.ts delete mode 100644 packages/networks/firo-rpc/lib/types.ts delete mode 100644 packages/networks/firo-rpc/tests/firoRpcNetwork.spec.ts delete mode 100644 packages/networks/firo-rpc/tests/mocked/rateLimitedAxios.mock.ts delete mode 100644 packages/networks/firo-rpc/tests/testData.ts diff --git a/.changeset/five-hands-argue.md b/.changeset/five-hands-argue.md new file mode 100644 index 000000000..c7781046b --- /dev/null +++ b/.changeset/five-hands-argue.md @@ -0,0 +1,5 @@ +--- +'guard-service': major +--- + +Replace firo-rpc with firo-electrumx, update config format from FIRO_RPC_* to FIRO_ELECTRUMX_* diff --git a/.changeset/metal-drums-wink.md b/.changeset/metal-drums-wink.md new file mode 100644 index 000000000..2017b6ca7 --- /dev/null +++ b/.changeset/metal-drums-wink.md @@ -0,0 +1,5 @@ +--- +'@rosen-chains/firo-electrumx': minor +--- + +Add firo-electrumx network package using ElectrumX TCP JSON-RPC protocol diff --git a/packages/networks/firo-rpc/README.md b/packages/networks/firo-electrumx/README.md similarity index 55% rename from packages/networks/firo-rpc/README.md rename to packages/networks/firo-electrumx/README.md index 2253f43d4..575531e2c 100644 --- a/packages/networks/firo-rpc/README.md +++ b/packages/networks/firo-electrumx/README.md @@ -1,26 +1,26 @@ -# @rosen-chains/firo-rpc +# @rosen-chains/firo-electrumx ## Table of contents -- [@rosen-chains/firo-rpc](#rosen-chainsfiro-rpc) +- [@rosen-chains/firo-electrumx](#rosen-chainsfiro-electrumx) - [Table of contents](#table-of-contents) - [Introduction](#introduction) - [Installation](#installation) ## Introduction -A package to be used as network api provider for @rosen-chains/firo package +A package to be used as network api provider for @rosen-chains/firo package, using ElectrumX TCP JSON-RPC protocol. ## Installation npm: ```sh -npm i @rosen-chains/firo-rpc +npm i @rosen-chains/firo-electrumx ``` yarn: ```sh -yarn add @rosen-chains/firo-rpc +yarn add @rosen-chains/firo-electrumx ``` diff --git a/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts b/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts new file mode 100644 index 000000000..90b27f55d --- /dev/null +++ b/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts @@ -0,0 +1,841 @@ +import * as crypto from 'crypto'; +import * as net from 'net'; + +import { Psbt } from 'bitcoinjs-lib'; +import { AbstractLogger } from '@rosen-bridge/abstract-logger'; +import { + BlockInfo, + FailedError, + NetworkError, + PaymentTransaction, + UnexpectedApiError, +} from '@rosen-chains/abstract-chain'; +import { + AbstractFiroNetwork, + FiroTx, + FiroUtxo, + FIRO_NETWORK, +} from '@rosen-chains/firo'; + +const BASE58_ALPHABET = + '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +function base58Decode(encoded: string): Buffer { + const bytes: number[] = []; + for (let i = 0; i < encoded.length; i++) { + const c = encoded[i]; + if (c === undefined) continue; + let carry = BASE58_ALPHABET.indexOf(c); + if (carry < 0) throw new Error(`Invalid base58 character: ${c}`); + for (let j = 0; j < bytes.length; j++) { + const b = bytes[j]; + if (b === undefined) continue; + carry += b * 58; + bytes[j] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + // Add leading zero bytes for each leading '1' in encoded + for (const ch of encoded) { + if (ch === '1') bytes.push(0); + else break; + } + return Buffer.from(bytes.reverse()); +} + +function addressToScripthash(address: string): string { + const decoded = base58Decode(address); + const pubkeyHash = decoded.subarray(1, 21); + // P2PKH script: OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG + const script = Buffer.concat([ + Buffer.from([0x76, 0xa9, 0x14]), + pubkeyHash, + Buffer.from([0x88, 0xac]), + ]); + const hash = crypto.createHash('sha256').update(script).digest(); + return hash.reverse().toString('hex'); +} + +function reverseHex(hex: string): string { + const buf = Buffer.from(hex, 'hex'); + return buf.reverse().toString('hex'); +} + +function doubleSha256(data: Buffer): Buffer { + return crypto.createHash('sha256').update( + crypto.createHash('sha256').update(data).digest() + ).digest(); +} + +function readVarInt(data: Buffer, offset: number): [number, number] { + const first = data[offset]; + if (first === undefined) throw new Error('Unexpected end of data'); + if (first < 0xfd) return [first, offset + 1]; + if (first === 0xfd) return [data.readUInt16LE(offset + 1), offset + 3]; + if (first === 0xfe) return [data.readUInt32LE(offset + 1), offset + 5]; + return [Number(data.readBigInt64LE(offset + 1)), offset + 9]; +} + +type FiroVerboseTransaction = { + confirmations?: number; +}; + +class FiroElectrumXNetwork extends AbstractFiroNetwork { + private readonly host: string; + private readonly port: number; + private readonly timeout: number; + private readonly getSavedTransactionById: ( + txId: string, + ) => Promise; + + private socket: net.Socket | null = null; + private responseBuffer = ''; + private pendingRequests: Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + > = new Map(); + private nextId = 1; + private connectPromise: Promise | null = null; + private serverVersionSent = false; + + // Block hash → height cache for resolving getBlockInfo/getBlockTransactionIds + private hashToHeight = new Map(); + private lastKnownHeight = 0; + + constructor( + host: string, + port: number, + getSavedTransactionById: ( + txId: string, + ) => Promise, + logger?: AbstractLogger, + timeout = 30000, + ) { + super(logger); + this.host = host; + this.port = port; + this.timeout = timeout; + this.getSavedTransactionById = getSavedTransactionById; + } + + private doConnect = (): Promise => { + return new Promise((resolve, reject) => { + const socket = net.createConnection(this.port, this.host); + socket.setEncoding('utf-8'); + socket.setNoDelay(true); + socket.setTimeout(this.timeout); + + let buffer = ''; + const versionPromise = new Promise((vResolve, vReject) => { + const versionId = 0; + const timer = setTimeout(() => { + socket.destroy(); + vReject(new Error('ElectrumX server.version timeout')); + }, this.timeout); + + const onData = (data: string) => { + buffer += data; + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const response = JSON.parse(line); + if (response.id === versionId) { + clearTimeout(timer); + socket.removeListener('data', onData); + vResolve(); + } + } catch { + // ignore parse errors + } + } + }; + socket.on('data', onData); + + socket.on('error', (err: Error) => { + clearTimeout(timer); + vReject(err); + }); + }); + + socket.once('connect', () => { + // Send server.version handshake + socket.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'server.version', + params: ['guard-service', '1.4'], + }) + '\n', + ); + + versionPromise + .then(() => { + this.serverVersionSent = true; + this.socket = socket; + this.setupSocketListeners(socket); + resolve(); + }) + .catch((err) => { + socket.destroy(); + reject(err); + }); + }); + + socket.once('error', (err: Error) => { + reject(err); + }); + }); + }; + + private setupSocketListeners = (socket: net.Socket) => { + socket.on('data', (data: string) => { + this.responseBuffer += data; + const lines = this.responseBuffer.split('\n'); + this.responseBuffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const response = JSON.parse(line); + const pending = this.pendingRequests.get(response.id); + if (pending) { + this.pendingRequests.delete(response.id); + if (response.error) { + pending.reject( + new Error( + `ElectrumX error: ${response.error.message || JSON.stringify(response.error)}`, + ), + ); + } else { + pending.resolve(response.result); + } + } + } catch { + // ignore parse errors + } + } + }); + + socket.on('error', (err: Error) => { + this.socket = null; + this.serverVersionSent = false; + this.rejectAllPending(new NetworkError(`TCP socket error: ${err.message}`)); + }); + + socket.on('close', () => { + this.socket = null; + this.serverVersionSent = false; + this.rejectAllPending(new NetworkError('TCP connection closed')); + }); + + socket.on('timeout', () => { + socket.destroy(); + this.socket = null; + this.serverVersionSent = false; + this.rejectAllPending(new NetworkError('TCP connection timeout')); + }); + }; + + private rejectAllPending = (error: Error) => { + for (const pending of this.pendingRequests.values()) { + pending.reject(error); + } + this.pendingRequests.clear(); + }; + + private ensureConnected = async (): Promise => { + if (this.socket && this.serverVersionSent && !this.socket.destroyed) { + return; + } + if (this.connectPromise) { + await this.connectPromise; + return; + } + this.connectPromise = this.doConnect(); + try { + await this.connectPromise; + } finally { + this.connectPromise = null; + } + }; + + private sendRequest = (method: string, params: unknown[]): Promise => { + return new Promise((resolve, reject) => { + const id = this.nextId++; + this.pendingRequests.set(id, { resolve, reject }); + + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`ElectrumX request timeout [${method}]`)); + }, this.timeout); + + const origResolve = resolve; + const origReject = reject; + this.pendingRequests.set(id, { + resolve: (value: unknown) => { + clearTimeout(timer); + origResolve(value); + }, + reject: (error: Error) => { + clearTimeout(timer); + origReject(error); + }, + }); + + try { + this.socket!.write( + JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params, + }) + '\n', + ); + } catch (err) { + clearTimeout(timer); + this.pendingRequests.delete(id); + reject( + new NetworkError( + `Failed to send ElectrumX request: ${err instanceof Error ? err.message : 'Unknown error'}`, + ), + ); + } + }); + }; + + // ─── AbstractChainNetwork methods ──────────────────────────────────────── + + getHeight = async (): Promise => { + try { + await this.ensureConnected(); + const result = (await this.sendRequest('blockchain.headers.subscribe', [])) as { + height: number; + }; + this.lastKnownHeight = result.height; + this.logger.debug(`Current height: ${result.height}`); + return result.height; + } catch (e) { + throw this.wrapError('Failed to fetch current height from Firo ElectrumX', e); + } + }; + + getBlockTransactionIds = async (blockId: string): Promise> => { + try { + const height = await this.resolveHeight(blockId); + await this.ensureConnected(); + const result = (await this.sendRequest('blockchain.block.txids', [ + height, + ])) as Array; + this.logger.debug( + `Block [${blockId}] at height [${height}] has ${result.length} transactions`, + ); + return result; + } catch (e) { + throw this.wrapError( + `Failed to get block [${blockId}] transaction ids from Firo ElectrumX`, + e, + ); + } + }; + + getBlockInfo = async (blockId: string): Promise => { + try { + const height = await this.resolveHeight(blockId); + await this.ensureConnected(); + const headerHex = (await this.sendRequest('blockchain.block.header', [ + height, + ])) as string; + const headerBytes = Buffer.from(headerHex, 'hex'); + if (headerBytes.length < 80) { + throw new Error(`Invalid block header length: ${headerBytes.length}`); + } + + // Parse block header (80 bytes) + const hash = reverseHex(doubleSha256(headerBytes).toString('hex')); + const parentHash = reverseHex( + headerBytes.subarray(4, 36).toString('hex'), + ); + + // Cache the mapping + this.hashToHeight.set(hash, height); + + this.logger.debug( + `Block [${blockId}] at height [${height}]: hash=${hash}, parent=${parentHash}`, + ); + + return { hash, parentHash, height }; + } catch (e) { + throw this.wrapError( + `Failed to get block [${blockId}] info from Firo ElectrumX`, + e, + ); + } + }; + + getTransaction = async ( + transactionId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + blockId: string, + ): Promise => { + try { + await this.ensureConnected(); + const txHex = (await this.sendRequest('blockchain.transaction.get', [ + transactionId, + ])) as string; + const firoTx = this.parseTransactionHex(txHex, transactionId); + this.logger.debug(`Fetched transaction [${transactionId}]`); + return firoTx; + } catch (e) { + throw this.wrapError( + `Failed to get transaction [${transactionId}] from Firo ElectrumX`, + e, + ); + } + }; + + submitTransaction = async (transaction: Psbt): Promise => { + const txHex = transaction.extractTransaction(true).toHex(); + try { + await this.ensureConnected(); + const result = (await this.sendRequest('blockchain.transaction.broadcast', [ + txHex, + ])) as string; + this.logger.debug(`Submitted transaction. Result: ${result}`); + } catch (e) { + throw this.wrapError( + 'Failed to submit transaction to Firo ElectrumX', + e, + ); + } + }; + + getMempoolTransactions = async (): Promise> => { + return []; + }; + + getTokenDetail = async (tokenId: string) => { + throw new Error( + `Firo network does not support token [${tokenId}]. Only native token is supported.`, + ); + }; + + getTxConfirmation = async (transactionId: string): Promise => { + const realTxId = await this.getActualTxId(transactionId); + return await this.getTxConfirmationSigned(realTxId); + }; + + getAddressAssets = async ( + address: string, + ): Promise<{ + nativeToken: bigint; + tokens: Array<{ id: string; value: bigint }>; + }> => { + try { + // Validate address contains only base58 characters + if (!/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/.test(address)) { + return { nativeToken: 0n, tokens: [] }; + } + const scripthash = addressToScripthash(address); + await this.ensureConnected(); + const result = (await this.sendRequest('blockchain.scripthash.get_balance', [ + scripthash, + ])) as { confirmed: number; unconfirmed: number }; + this.logger.debug( + `Address [${address}] balance: confirmed=${result.confirmed}, unconfirmed=${result.unconfirmed}`, + ); + return { + nativeToken: BigInt(result.confirmed), + tokens: [], + }; + } catch (e) { + throw this.wrapError( + `Failed to get address assets for [${address}] from Firo ElectrumX`, + e, + ); + } + }; + + // ─── AbstractUtxoChainNetwork methods ──────────────────────────────────── + + getAddressBoxes = async ( + address: string, + offset: number, + limit: number, + ): Promise> => { + try { + // Validate address contains only base58 characters + if (!/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/.test(address)) { + return []; + } + const scripthash = addressToScripthash(address); + await this.ensureConnected(); + const utxos = (await this.sendRequest('blockchain.scripthash.listunspent', [ + scripthash, + ])) as Array<{ + tx_hash: string; + tx_pos: number; + height: number; + value: number; + }>; + + // Filter unconfirmed (height=0), then paginate + const firoUtxos = utxos + .filter((utxo) => utxo.height > 0) + .slice(offset, offset + limit) + .map((utxo) => ({ + txId: utxo.tx_hash, + index: utxo.tx_pos, + value: BigInt(utxo.value), + })); + + this.logger.debug( + `Address [${address}] has ${utxos.length} UTXOs, returning ${firoUtxos.length} after pagination`, + ); + return firoUtxos; + } catch (e) { + throw this.wrapError( + `Failed to get address boxes for [${address}] from Firo ElectrumX`, + e, + ); + } + }; + + isBoxUnspentAndValid = async (boxId: string): Promise => { + const [txId, outputIndexStr] = boxId.split('.'); + const outputIndex = parseInt(outputIndexStr, 10); + + try { + await this.ensureConnected(); + // Get the transaction to verify the output exists + const txHex = (await this.sendRequest('blockchain.transaction.get', [ + txId, + ])) as string; + const tx = this.parseTransactionHex(txHex, txId); + + if (!tx || outputIndex >= tx.outputs.length) { + return false; + } + + // Check if the output is unspent by computing its scripthash + // and checking against listunspent + const scriptPubKey = tx.outputs[outputIndex]!.scriptPubKey; + const scripthash = reverseHex( + crypto.createHash('sha256').update(Buffer.from(scriptPubKey, 'hex')).digest().toString('hex'), + ); + + const unspent = (await this.sendRequest('blockchain.scripthash.listunspent', [ + scripthash, + ])) as Array<{ tx_hash: string; tx_pos: number }>; + + return unspent.some( + (utxo) => utxo.tx_hash === txId && utxo.tx_pos === outputIndex, + ); + } catch (e: unknown) { + // If transaction not found, box is not valid + if (e instanceof Error && e.message.includes('No such transaction')) { + return false; + } + throw this.wrapError( + `Failed to check if box [${boxId}] is unspent from Firo ElectrumX`, + e, + ); + } + }; + + // ─── AbstractFiroNetwork methods ───────────────────────────────────────── + + getUtxo = async (boxId: string): Promise => { + const [txId, outputIndexStr] = boxId.split('.'); + const outputIndex = parseInt(outputIndexStr, 10); + + try { + await this.ensureConnected(); + const txHex = (await this.sendRequest('blockchain.transaction.get', [ + txId, + ])) as string; + const tx = this.parseTransactionHex(txHex, txId); + + if (!tx || outputIndex >= tx.outputs.length) { + throw new FailedError(`UTXO with boxId [${boxId}] not found`); + } + + return { + txId, + index: outputIndex, + value: tx.outputs[outputIndex]!.value, + }; + } catch (e) { + if (e instanceof FailedError) throw e; + throw this.wrapError( + `Failed to get UTXO [${boxId}] from Firo ElectrumX`, + e, + ); + } + }; + + getFeeRatio = async (): Promise => { + try { + await this.ensureConnected(); + const feeRate = (await this.sendRequest('blockchain.estimatefee', [ + 6, + ])) as number; + // ElectrumX returns fee in BTC/kB, convert to satoshis/byte + if (feeRate <= 0) { + // ElectrumX returns -1 when it cannot estimate (not enough data). + // Use a conservative fallback: 10 sat/byte covers typical low-fee periods. + this.logger.warn( + `ElectrumX estimatefee returned ${feeRate}, using fallback 10 sat/byte`, + ); + return 10; + } + const feeSatoshis = Math.ceil(feeRate * 100000000); + const feePerByte = Math.ceil(feeSatoshis / 1000); + this.logger.debug(`Fee ratio: ${feePerByte} sat/byte`); + return feePerByte; + } catch (e) { + throw this.wrapError( + 'Failed to get fee ratio from Firo ElectrumX', + e, + ); + } + }; + + isTxInMempool = async (txId: string): Promise => { + try { + await this.ensureConnected(); + const tx = (await this.sendRequest('blockchain.transaction.get', [ + txId, + true, + ])) as FiroVerboseTransaction; + return (tx.confirmations ?? 0) <= 0; + } catch { + return false; + } + }; + + getTransactionHex = async (txId: string): Promise => { + try { + await this.ensureConnected(); + const txHex = (await this.sendRequest('blockchain.transaction.get', [ + txId, + ])) as string; + this.logger.debug(`Fetched transaction hex for txId [${txId}]`); + return txHex; + } catch (e) { + throw this.wrapError( + `Failed to get transaction hex [${txId}] from Firo ElectrumX`, + e, + ); + } + }; + + // ─── Transaction ID resolution ─────────────────────────────────────────── + + protected getTxConfirmationSigned = async ( + transactionId: string, + ): Promise => { + try { + await this.ensureConnected(); + const tx = (await this.sendRequest('blockchain.transaction.get', [ + transactionId, + true, + ])) as FiroVerboseTransaction; + const confirmations = tx.confirmations ?? 0; + if (confirmations <= 0) { + this.logger.debug(`tx [${transactionId}] has no confirmations`); + return -1; + } + this.logger.debug( + `tx [${transactionId}] has ${confirmations} confirmations`, + ); + return confirmations; + } catch (e) { + if (e instanceof Error && e.message.includes('No such transaction')) { + this.logger.debug(`tx [${transactionId}] is not found`); + return -1; + } + this.logger.debug( + `tx [${transactionId}] verbose lookup failed, assuming unconfirmed: ${e}`, + ); + return -1; + } + }; + + /* eslint-disable @typescript-eslint/no-unused-vars */ + protected getSpentTransactionByInputId = async ( + _index: number, + _txId: string, + ): Promise => { + // ElectrumX does not have getspentinfo equivalent. + // This is used as a fallback for getActualTxId. + // Direct PSBT extraction (method 1) handles most cases. + return undefined; + }; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + protected extractActualTxIdFromPsbt = async ( + psbt: Psbt, + ): Promise => { + try { + return psbt.extractTransaction(true).getId(); + } catch (error) { + this.logger.debug( + `Failed to extract signed transaction ID from PSBT: ${error}`, + ); + return undefined; + } + }; + + /* eslint-disable @typescript-eslint/no-unused-vars */ + protected extractActualTxIdWithRpcLookup = async ( + _psbt: Psbt, + ): Promise => { + // ElectrumX doesn't support getspentinfo, so we can't do RPC-based lookup. + // Direct PSBT extraction (method 1) handles the common case. + return undefined; + }; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + getActualTxId = async (hash: string): Promise => { + let actualTxId = hash; + try { + const realPaymentTx = await this.getSavedTransactionById(hash); + + if (realPaymentTx) { + const realTx = Psbt.fromBuffer(Buffer.from(realPaymentTx.txBytes), { + network: FIRO_NETWORK, + }); + + // Method 1: Try direct PSBT extraction + const directExtraction = await this.extractActualTxIdFromPsbt(realTx); + if (directExtraction) { + actualTxId = directExtraction; + } else { + // Method 2: Fallback (no RPC available for ElectrumX) + this.logger.debug( + `Direct PSBT extraction failed for hash [${hash}]. RPC lookup not available with ElectrumX.`, + ); + } + } + } catch (e) { + throw this.wrapError( + `Failed to get actual txId for tx [${hash}] from database`, + e, + ); + } + + return actualTxId; + }; + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private resolveHeight = async (blockHash: string): Promise => { + // Check cache first + const cached = this.hashToHeight.get(blockHash); + if (cached !== undefined) return cached; + + // Try to find it by searching from current height backwards + await this.ensureConnected(); + const searchStart = this.lastKnownHeight > 0 ? this.lastKnownHeight : ( + (await this.sendRequest('blockchain.headers.subscribe', [])) as { + height: number; + } + ).height; + this.lastKnownHeight = searchStart; + + // Search up to 1000 blocks back (~42 hours at 2 blocks per 5 minutes) + for (let h = searchStart; h > searchStart - 1000 && h > 0; h--) { + const headerHex = (await this.sendRequest('blockchain.block.header', [ + h, + ])) as string; + const headerBytes = Buffer.from(headerHex, 'hex'); + if (headerBytes.length < 80) continue; + const hash = reverseHex(doubleSha256(headerBytes).toString('hex')); + this.hashToHeight.set(hash, h); + if (hash === blockHash) return h; + } + + throw new Error( + `Block [${blockHash}] not found within 1000 blocks of height ${searchStart}`, + ); + }; + + /** + * Parse a raw Firo transaction hex into a FiroTx object. + * Firo packs transaction type into the upper 16 bits of the version field. + */ + private parseTransactionHex = (hex: string, txid: string): FiroTx => { + const buf = Buffer.from(hex, 'hex'); + let offset = 0; + + // Version/type field (4 bytes, LE) + offset += 4; + + // Vin + const [vinCount, newOffset1] = readVarInt(buf, offset); + offset = newOffset1; + const inputs: Array<{ txId: string; index: number; scriptPubKey: string }> = + []; + for (let i = 0; i < vinCount; i++) { + // Previous tx hash (32 bytes) + const prevTxHash = buf.subarray(offset, offset + 32); + offset += 32; + // Previous output index (4 bytes, LE) + const prevIndex = buf.readUInt32LE(offset); + offset += 4; + // ScriptSig length (varint) + const [scriptSigLen, scriptOffset] = readVarInt(buf, offset); + offset = scriptOffset; + // ScriptSig + const scriptSig = buf.subarray(offset, offset + scriptSigLen).toString('hex'); + offset += scriptSigLen; + // Sequence (4 bytes) + offset += 4; + + const txIdStr = prevTxHash.reverse().toString('hex'); + const isCoinbase = txIdStr === '0000000000000000000000000000000000000000000000000000000000000000'; + inputs.push({ + txId: isCoinbase ? '' : txIdStr, + index: isCoinbase ? -1 : prevIndex, + scriptPubKey: scriptSig, + }); + } + + // Vout + const [voutCount, newOffset2] = readVarInt(buf, offset); + offset = newOffset2; + const outputs: Array<{ scriptPubKey: string; value: bigint }> = []; + for (let i = 0; i < voutCount; i++) { + // Value (8 bytes, LE, as BigInt) + const value = buf.readBigInt64LE(offset); + offset += 8; + // ScriptPubKey length (varint) + const [scriptLen, scriptOff] = readVarInt(buf, offset); + offset = scriptOff; + // ScriptPubKey + const scriptPubKey = buf.subarray(offset, offset + scriptLen).toString('hex'); + offset += scriptLen; + + outputs.push({ scriptPubKey, value }); + } + + return { id: txid, inputs, outputs }; + }; + + private wrapError = (baseMessage: string, e: unknown): Error => { + if (e instanceof FailedError || e instanceof NetworkError || e instanceof UnexpectedApiError) { + return e; + } + if (e instanceof Error) { + return new NetworkError(`${baseMessage}: ${e.message}`); + } + return new UnexpectedApiError(`${baseMessage}: Unknown error`); + }; +} + +export default FiroElectrumXNetwork; diff --git a/packages/networks/firo-electrumx/lib/index.ts b/packages/networks/firo-electrumx/lib/index.ts new file mode 100644 index 000000000..960cba648 --- /dev/null +++ b/packages/networks/firo-electrumx/lib/index.ts @@ -0,0 +1 @@ +export { default as FiroElectrumXNetwork } from './firoElectrumxNetwork'; diff --git a/packages/networks/firo-rpc/package.json b/packages/networks/firo-electrumx/package.json similarity index 85% rename from packages/networks/firo-rpc/package.json rename to packages/networks/firo-electrumx/package.json index 2f1bb1b6d..5b919ee0f 100644 --- a/packages/networks/firo-rpc/package.json +++ b/packages/networks/firo-electrumx/package.json @@ -1,14 +1,14 @@ { - "name": "@rosen-chains/firo-rpc", + "name": "@rosen-chains/firo-electrumx", "version": "0.0.0", - "description": "A package to be used as network api provider for @rosen-chains/firo package using RPC API", + "description": "A package to be used as network api provider for @rosen-chains/firo package using ElectrumX TCP API", "keywords": [ "rosen" ], "repository": { "type": "git", "url": "git+https://github.com/rosen-bridge/guard-service.git", - "directory": "packages/networks/firo-rpc" + "directory": "packages/networks/firo-electrumx" }, "license": "MIT", "author": "Navid Rahimi", @@ -33,10 +33,8 @@ }, "dependencies": { "@rosen-bridge/abstract-logger": "^4.0.0", - "@rosen-bridge/json-bigint": "^1.1.0", "@rosen-chains/abstract-chain": "^16.0.0", "@rosen-chains/firo": "^0.0.0", - "@rosen-clients/rate-limited-axios": "^2.0.0", "bitcoinjs-lib": "^6.1.5" }, "engines": { diff --git a/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts b/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts new file mode 100644 index 000000000..03d448171 --- /dev/null +++ b/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts @@ -0,0 +1,724 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + FailedError, + NetworkError, + PaymentTransaction, + TransactionType, +} from '@rosen-chains/abstract-chain'; + +import { setMockResponses, resetMock } from './mocked/electrumxSocket.mock'; +import FiroElectrumXNetwork from '../lib/firoElectrumxNetwork'; +import * as testData from './testData'; + +describe('FiroElectrumXNetwork', () => { + const HOST = '127.0.0.1'; + const PORT = 50001; + const mockGetSavedTransactionById = vi.fn().mockReturnValue(undefined); + + beforeEach(() => { + resetMock(); + mockGetSavedTransactionById.mockReset(); + mockGetSavedTransactionById.mockReturnValue(undefined); + }); + + describe('getHeight', () => { + /** + * @target `FiroElectrumXNetwork.getHeight` should return block height successfully + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX blockchain.headers.subscribe response + * - create new instance of FiroElectrumXNetwork + * - call getHeight + * @expected + * - it should return mocked block height + */ + it('should return block height successfully', async () => { + setMockResponses([testData.blockHeightResponse]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getHeight(); + + expect(result).toEqual(testData.blockHeightResponse.height); + }); + + /** + * @target `FiroElectrumXNetwork.getHeight` should throw NetworkError on TCP error + * @dependencies + * - net (TCP) + * @scenario + * - mock TCP connection to fail + * @expected + * - it should throw NetworkError + */ + it('should throw NetworkError on TCP error', async () => { + const { createConnection } = await import('net'); + vi.mocked(createConnection).mockImplementationOnce(() => { + throw new Error('Connection refused'); + }); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + await expect(network.getHeight()).rejects.toThrow(NetworkError); + }); + }); + + describe('getBlockTransactionIds', () => { + /** + * @target `FiroElectrumXNetwork.getBlockTransactionIds` should return block tx ids + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX blockchain.block.txids response + * - pre-populate hash→height cache via getBlockInfo/resolveHeight + * @expected + * - it should return mocked tx ids + */ + it('should return block tx ids successfully', async () => { + setMockResponses([testData.blockTxIds]); // only blockchain.block.txids is called + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + // Pre-populate the height cache so resolveHeight returns immediately + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (network as any).hashToHeight.set(testData.blockHash, 42); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (network as any).lastKnownHeight = 43; + + const result = await network.getBlockTransactionIds(testData.blockHash); + expect(result).toEqual(testData.blockTxIds); + }); + }); + + describe('getBlockInfo', () => { + /** + * @target `FiroElectrumXNetwork.getBlockInfo` should return block info + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX blockchain.block.header response + * - pre-populate hash→height cache + * @expected + * - it should return correct hash, parentHash, and height + */ + it('should return block info successfully', async () => { + setMockResponses([ + testData.blockHeaderHex, // blockchain.block.header response + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (network as any).hashToHeight.set(testData.blockHash, testData.blockInfo.height); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (network as any).lastKnownHeight = testData.blockInfo.height; + + const result = await network.getBlockInfo(testData.blockHash); + expect(result.hash).toBe(testData.blockInfo.hash); + expect(result.parentHash).toBe(testData.blockInfo.parentHash); + expect(result.height).toBe(testData.blockInfo.height); + }); + }); + + describe('getTransaction', () => { + /** + * @target `FiroElectrumXNetwork.getTransaction` should return transaction + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX blockchain.transaction.get response + * @expected + * - it should return parsed FiroTx + */ + it('should return transaction successfully', async () => { + setMockResponses([testData.txHex]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getTransaction( + testData.txId, + testData.txBlockHash, + ); + + expect(result.id).toEqual(testData.txId); + expect(result.inputs.length).toEqual(testData.firoTx.inputs.length); + expect(result.outputs.length).toEqual(testData.firoTx.outputs.length); + expect(result.outputs[0]!.value).toEqual(testData.firoTx.outputs[0]!.value); + expect(result.outputs[0]!.scriptPubKey).toEqual( + testData.firoTx.outputs[0]!.scriptPubKey, + ); + }); + + it('should parse a version 3 Firo transaction with packed type', async () => { + setMockResponses([testData.txHexV3Typed]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getTransaction( + testData.txId, + testData.txBlockHash, + ); + + expect(result.id).toEqual(testData.txId); + expect(result.inputs.length).toEqual(testData.firoTx.inputs.length); + expect(result.outputs.length).toEqual(testData.firoTx.outputs.length); + expect(result.outputs[0]!.value).toEqual(testData.firoTx.outputs[0]!.value); + expect(result.outputs[1]!.scriptPubKey).toEqual( + testData.firoTx.outputs[1]!.scriptPubKey, + ); + }); + }); + + describe('isBoxUnspentAndValid', () => { + /** + * @target `FiroElectrumXNetwork.isBoxUnspentAndValid` should return true for unspent output + * @dependencies + * - net (TCP) + * @scenario + * - mock transaction hex and listunspent containing the box + * @expected + * - it should return true + */ + it('should return true for unspent output', async () => { + setMockResponses([ + testData.txHex, + [{ tx_hash: testData.txId, tx_pos: 0, height: 42, value: 119595114000 }], + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.isBoxUnspentAndValid(`${testData.txId}.0`); + + expect(result).toEqual(true); + }); + + /** + * @target `FiroElectrumXNetwork.isBoxUnspentAndValid` should return false for spent output + * @dependencies + * - net (TCP) + * @scenario + * - mock transaction hex but listunspent not containing the box + * @expected + * - it should return false + */ + it('should return false for spent output', async () => { + setMockResponses([ + testData.txHex, + [], // empty listunspent means output is spent + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.isBoxUnspentAndValid(`${testData.txId}.0`); + + expect(result).toEqual(false); + }); + + /** + * @target `FiroElectrumXNetwork.isBoxUnspentAndValid` should return false when tx doesn't exist + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX to return error for non-existent tx + * @expected + * - it should return false + */ + it("should return false when transaction doesn't exist", async () => { + // Simulate error by providing no response (the mock will time out) + // Actually, the mock handles this by having sendRequest reject for missing response + // Better approach: use a real error response from ElectrumX + setMockResponses([ + { error: { message: 'No such transaction', code: -5 } }, + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.isBoxUnspentAndValid(`nonexistent.0`); + + expect(result).toEqual(false); + }); + }); + + describe('getUtxo', () => { + /** + * @target `FiroElectrumXNetwork.getUtxo` should return UTXO data + * @dependencies + * - net (TCP) + * @scenario + * - mock transaction hex with the requested output + * @expected + * - it should return correct UTXO + */ + it('should return UTXO data successfully', async () => { + setMockResponses([testData.txHex]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getUtxo(`${testData.txId}.0`); + + expect(result.txId).toEqual(testData.txId); + expect(result.index).toEqual(0); + expect(result.value).toEqual(testData.firoUtxo.value); + }); + + /** + * @target `FiroElectrumXNetwork.getUtxo` should throw FailedError for invalid output index + * @dependencies + * - net (TCP) + * @scenario + * - mock transaction hex, request non-existent output index + * @expected + * - it should throw FailedError + */ + it('should throw FailedError for invalid output index', async () => { + setMockResponses([testData.txHex]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + await expect(network.getUtxo(`${testData.txId}.999`)).rejects.toThrow( + FailedError, + ); + }); + }); + + describe('getFeeRatio', () => { + /** + * @target `FiroElectrumXNetwork.getFeeRatio` should return fee ratio + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX blockchain.estimatefee response + * @expected + * - it should return calculated fee ratio in satoshis/byte + */ + it('should return fee ratio successfully', async () => { + setMockResponses([testData.estimatedFee]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getFeeRatio(); + + const expectedFeeRate = Math.ceil( + Math.ceil(testData.estimatedFee * 100000000) / 1000, + ); + expect(result).toEqual(expectedFeeRate); + }); + }); + + describe('isTxInMempool', () => { + /** + * @target `FiroElectrumXNetwork.isTxInMempool` should return true when tx is in mempool + * @dependencies + * - net (TCP) + * @scenario + * - mock verbose get to return an existing tx without confirmations + * @expected + * - it should return true + */ + it('should return true when tx is in mempool', async () => { + setMockResponses([ + { hex: testData.txHex }, + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.isTxInMempool(testData.txId); + + expect(result).toEqual(true); + }); + + /** + * @target `FiroElectrumXNetwork.isTxInMempool` should return false when tx is confirmed + * @dependencies + * - net (TCP) + * @scenario + * - mock verbose get to return a positive confirmation count + * @expected + * - it should return false (not in mempool, it's confirmed) + */ + it('should return false when tx is confirmed', async () => { + setMockResponses([ + { + hex: testData.txHex, + blockhash: testData.txBlockHash, + confirmations: 1, + }, + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.isTxInMempool(testData.txId); + + expect(result).toEqual(false); + }); + + /** + * @target `FiroElectrumXNetwork.isTxInMempool` should return false when tx is not found + * @dependencies + * - net (TCP) + * @scenario + * - mock verbose get to fail + * @expected + * - it should return false + */ + it('should return false when tx is not found', async () => { + setMockResponses([ + { error: { message: 'not found', code: -1 } }, + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.isTxInMempool(testData.txId); + + expect(result).toEqual(false); + }); + }); + + describe('getTransactionHex', () => { + /** + * @target `FiroElectrumXNetwork.getTransactionHex` should return transaction hex + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX blockchain.transaction.get response + * @expected + * - it should return the raw hex string + */ + it('should return transaction hex successfully', async () => { + setMockResponses([testData.txHex]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getTransactionHex(testData.txId); + + expect(result).toEqual(testData.txHex); + }); + }); + + describe('submitTransaction', () => { + /** + * @target `FiroElectrumXNetwork.submitTransaction` should submit transaction + * @dependencies + * - net (TCP), bitcoinjs-lib Psbt + * @scenario + * - mock Psbt for transaction extraction + * - mock ElectrumX broadcast response + * @expected + * - it should not throw error + */ + it('should submit transaction successfully', async () => { + setMockResponses([testData.txId]); // broadcast returns txid + + const mockPsbt = { + finalizeAllInputs: vi.fn(), + extractTransaction: vi.fn().mockReturnValue({ + toHex: vi.fn().mockReturnValue('01000000...'), + }), + }; + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + network.submitTransaction(mockPsbt as any), + ).resolves.not.toThrow(); + }); + }); + + describe('getAddressBoxes', () => { + /** + * @target `FiroElectrumXNetwork.getAddressBoxes` should return address UTXOs with pagination + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX listunspent response + * @expected + * - it should return paginated UTXOs in correct format + */ + it('should return address UTXOs successfully with pagination', async () => { + setMockResponses([testData.mockAddressUtxos]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getAddressBoxes(testData.lockAddress, 0, 2); + + expect(result).toEqual(testData.expectedAddressBoxes); + }); + + /** + * @target `FiroElectrumXNetwork.getAddressBoxes` should handle empty address + * @dependencies + * - net (TCP) + * @scenario + * - mock empty listunspent response + * @expected + * - it should return empty array + */ + it('should handle empty address', async () => { + setMockResponses([[]]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getAddressBoxes('empty-address', 0, 10); + + expect(result).toEqual([]); + }); + }); + + describe('getTxConfirmation', () => { + /** + * @target `FiroElectrumXNetwork.getTxConfirmation` should return confirmation count + * @dependencies + * - net (TCP) + * @scenario + * - mock verbose get to return Firo Core confirmation count + * @expected + * - it should return correct number of confirmations + */ + it('should return confirmation count successfully', async () => { + setMockResponses([ + { + hex: testData.txHex, + blockhash: testData.txBlockHash, + confirmations: testData.expectedTxConfirmation, + }, + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getTxConfirmation(testData.txId); + + expect(result).toEqual(testData.expectedTxConfirmation); + }); + + /** + * @target `FiroElectrumXNetwork.getTxConfirmation` should return -1 for unconfirmed tx + * @dependencies + * - net (TCP) + * @scenario + * - mock verbose get to return an existing tx without confirmations + * @expected + * - it should return -1 + */ + it('should return -1 for unconfirmed transaction', async () => { + setMockResponses([ + { hex: testData.txHex }, + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getTxConfirmation(testData.txId); + + expect(result).toEqual(-1); + }); + + /** + * @target `FiroElectrumXNetwork.getTxConfirmation` should return -1 when tx not found + * @dependencies + * - net (TCP) + * @scenario + * - mock verbose get to throw + * @expected + * - it should return -1 + */ + it('should return -1 when transaction is not found', async () => { + setMockResponses([ + { error: { message: 'not found', code: -1 } }, + ]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getTxConfirmation('nonexistent-tx-id'); + + expect(result).toEqual(-1); + }); + + /** + * @target `FiroElectrumXNetwork.getTxConfirmation` should handle tx with unsigned hash + * @dependencies + * - net (TCP), bitcoinjs-lib Psbt + * @scenario + * - create custom getSavedTransactionById returning a payment tx + * - mock direct PSBT extraction success + * - mock verbose get confirmation response + * @expected + * - it should resolve unsigned hash and return confirmations + */ + it('should fetch confirmation using unsigned hash successfully', async () => { + const firoPayment = new PaymentTransaction( + 'firo', + testData.unsignedTxId, + 'eventId', + Buffer.from(testData.firoPaymentBytes, 'hex'), + TransactionType.payment, + ); + + const customNetwork = new FiroElectrumXNetwork(HOST, PORT, async (txId: string) => { + if (txId === testData.unsignedTxId) { + return firoPayment; + } + return undefined; + }); + + const getTxConfirmationSignedSpy = vi.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + customNetwork as any, + 'getTxConfirmationSigned', + ); + + vi.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + customNetwork as any, + 'extractActualTxIdFromPsbt', + ).mockResolvedValue(testData.txId); + + setMockResponses([ + { + hex: testData.txHex, + blockhash: testData.txBlockHash, + confirmations: testData.expectedTxConfirmation, + }, + ]); + + const result = await customNetwork.getTxConfirmation(testData.unsignedTxId); + + expect(getTxConfirmationSignedSpy).toHaveBeenCalledExactlyOnceWith( + testData.txId, + ); + expect(result).toEqual(testData.expectedTxConfirmation); + }); + }); + + describe('getAddressAssets', () => { + /** + * @target `FiroElectrumXNetwork.getAddressAssets` should return address balance + * @dependencies + * - net (TCP) + * @scenario + * - mock ElectrumX blockchain.scripthash.get_balance response + * @expected + * - it should return native token balance + */ + it('should return address balance successfully', async () => { + setMockResponses([testData.balanceResponse]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getAddressAssets(testData.lockAddress); + + expect(result.nativeToken).toEqual(testData.expectedAddressBalance); + expect(result.tokens).toEqual([]); + }); + + /** + * @target `FiroElectrumXNetwork.getAddressAssets` should return 0 for empty address + * @dependencies + * - net (TCP) + * @scenario + * - mock zero balance response + * @expected + * - it should return 0 balance + */ + it('should return 0 for empty address', async () => { + setMockResponses([{ confirmed: 0, unconfirmed: 0 }]); + + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getAddressAssets('empty-address'); + + expect(result.nativeToken).toEqual(0n); + expect(result.tokens).toEqual([]); + }); + }); + + describe('getSpentTransactionByInputId', () => { + /** + * @target `FiroElectrumXNetwork.getSpentTransactionByInputId` should return undefined + * @dependencies + * - none + * @scenario + * - call getSpentTransactionByInputId (ElectrumX has no getspentinfo) + * @expected + * - it should return undefined + */ + it('should return undefined (no getspentinfo in ElectrumX)', async () => { + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (network as any).getSpentTransactionByInputId( + 0, + testData.txId, + ); + + expect(result).toBeUndefined(); + }); + }); + + describe('getActualTxId', () => { + /** + * @target `FiroElectrumXNetwork.getActualTxId` should return same hash when no saved tx + * @dependencies + * - none + * @scenario + * - call getActualTxId with hash, no saved tx in DB + * @expected + * - it should return the same hash + */ + it('should return the same hash when no saved transaction exists', async () => { + const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const result = await network.getActualTxId(testData.txId); + + expect(result).toEqual(testData.txId); + }); + + /** + * @target `FiroElectrumXNetwork.getActualTxId` should extract signed txId from PSBT (method 1) + * @dependencies + * - bitcoinjs-lib Psbt + * @scenario + * - create custom getSavedTransactionById returning payment tx + * - mock direct PSBT extraction to succeed + * @expected + * - it should return signed txId + */ + it('should extract signed txId from PSBT using direct method', async () => { + const firoPayment = new PaymentTransaction( + 'firo', + testData.unsignedTxId, + 'eventId', + Buffer.from(testData.firoPaymentBytes, 'hex'), + TransactionType.payment, + ); + + const customNetwork = new FiroElectrumXNetwork(HOST, PORT, async (txId: string) => { + if (txId === testData.unsignedTxId) { + return firoPayment; + } + return undefined; + }); + + const extractDirectSpy = vi + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(customNetwork as any, 'extractActualTxIdFromPsbt') + .mockResolvedValue(testData.txId); + + const result = await customNetwork.getActualTxId(testData.unsignedTxId); + + expect(extractDirectSpy).toHaveBeenCalled(); + expect(result).toEqual(testData.txId); + }); + + /** + * @target `FiroElectrumXNetwork.getActualTxId` should return original hash when extraction fails + * @dependencies + * - bitcoinjs-lib Psbt + * @scenario + * - mock both extraction methods to fail + * @expected + * - it should return original hash + */ + it('should return original hash when extraction fails', async () => { + const firoPayment = new PaymentTransaction( + 'firo', + testData.unsignedTxId, + 'eventId', + Buffer.from(testData.firoPaymentBytes, 'hex'), + TransactionType.payment, + ); + + const customNetwork = new FiroElectrumXNetwork(HOST, PORT, async (txId: string) => { + if (txId === testData.unsignedTxId) { + return firoPayment; + } + return undefined; + }); + + vi.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + customNetwork as any, + 'extractActualTxIdFromPsbt', + ).mockResolvedValue(undefined); + + const result = await customNetwork.getActualTxId(testData.unsignedTxId); + + expect(result).toEqual(testData.unsignedTxId); + }); + }); +}); diff --git a/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts b/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts new file mode 100644 index 000000000..b05e4a5c7 --- /dev/null +++ b/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts @@ -0,0 +1,98 @@ +import { vi } from 'vitest'; +import { EventEmitter } from 'events'; + +let mockResponses: Array = []; + +export function setMockResponses(responses: Array) { + mockResponses = [...responses]; +} + +export function resetMock() { + mockResponses = []; +} + +function createMockSocket() { + const socket = new EventEmitter() as EventEmitter & { + written: string[]; + destroyed: boolean; + write: (data: string) => boolean; + destroy: () => void; + setEncoding: () => void; + setNoDelay: () => void; + setTimeout: () => void; + end: () => void; + ref: () => void; + unref: () => void; + }; + socket.written = []; + socket.destroyed = false; + + socket.write = (data: string) => { + socket.written.push(data); + const lines = data.split('\n').filter((l) => l.trim()); + for (const line of lines) { + try { + const req = JSON.parse(line); + if (req.method === 'server.version') { + setTimeout(() => + socket.emit( + 'data', + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + result: ['guard-service', '1.4'], + }) + '\n', + ), + 0, + ); + } else { + const resp = mockResponses.shift(); + if (resp !== undefined && resp !== null) { + const isError = + typeof resp === 'object' && + 'error' in resp && + resp.error !== null && + resp.error !== undefined; + setTimeout(() => + socket.emit( + 'data', + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + [isError ? 'error' : 'result']: isError ? resp.error : resp, + }) + '\n', + ), + 0, + ); + } + } + } catch { + /* ignore */ + } + } + return true; + }; + + socket.destroy = () => { + socket.destroyed = true; + }; + socket.setEncoding = vi.fn(); + socket.setNoDelay = vi.fn(); + socket.setTimeout = vi.fn(); + socket.end = vi.fn(); + socket.ref = vi.fn(); + socket.unref = vi.fn(); + + return socket; +} + +// Mock the 'net' module +vi.mock('net', () => { + return { + createConnection: vi.fn(() => { + const socket = createMockSocket(); + setTimeout(() => socket.emit('connect'), 0); + return socket; + }), + }; +}); diff --git a/packages/networks/firo-electrumx/tests/testData.ts b/packages/networks/firo-electrumx/tests/testData.ts new file mode 100644 index 000000000..dd5f813c7 --- /dev/null +++ b/packages/networks/firo-electrumx/tests/testData.ts @@ -0,0 +1,101 @@ +export const lockAddress = 'DHTom1rFwsgAn5raKU1nok8E5MdQ4GBkAN'; +export const lockAddressPublicKey = + '76a914872b67c8270a9eaf5c2abf632af3dea989d2e37188ac'; + +// 80-byte block header hex (valid, produces deterministic hash) +export const blockHeaderHex = + '010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080924a66ffff001d00000000'; + +// Block hash computed from header (double-SHA256, reversed) +export const blockHash = + 'ea7f792f3d80c362770b598bb1756e52fbf451a50b8e59f7b0398063e3134220'; + +// ElectrumX blockchain.headers.subscribe response +export const blockHeightResponse = { + hex: blockHeaderHex, + height: 42, +}; + +// Block info object +export const blockInfo = { + hash: blockHash, + parentHash: + '0000000000000000000000000000000000000000000000000000000000000000', + height: 42, +}; + +// Transaction IDs +export const txId = + '87ce994dacf48d97dcffd30221f70acf8c2b40ba4d5ed9be8615d79daf922c73'; +export const txBlockHash = + '491256bdad2121de4a07e640795398f92da33e6082bab6c2c859f2be2a48ad1a'; +export const unsignedTxId = 'unsigned_tx_id_placeholder'; + +// Raw transaction hex (version 1, 1 input, 2 outputs) +export const txHex = + '0100000001334a2a5e41047070a5497cf208b3c408998bc4be3487b8125e244bbfb742d915000000006b483045022100dce96e89af41443891626f059ab6934a5b8ac76de3b6881cdc87a0c1c578cc070220054d44f9b6241fe9fbd10482ae5f285c273025324538a3d7e419d7f3dde69d0d012103dc1945a85a6147ed5da6d6f150e9de002e40c4ff48cca96b488fe74fa9af8a88ffffffff02109e6cd81b0000001976a9144883eb0a391995f422a48595edf7a19af5e0660c88ac3b70f1bd731b00001976a914a17fdccb11e75bf95df8f760fde346357f34c7ec88ac00000000'; + +// Same transaction shape with version 3 and type 8 packed into the 4-byte field. +export const txHexV3Typed = `03000800${txHex.slice(8)}00`; + +// Parsed FiroTx +export const firoTx = { + id: txId, + inputs: [ + { + txId: '15d942b7bf4b245e12b88734bec48b9908c4b308f27c49a5707004415e2a4a33', + index: 0, + scriptPubKey: + '483045022100dce96e89af41443891626f059ab6934a5b8ac76de3b6881cdc87a0c1c578cc070220054d44f9b6241fe9fbd10482ae5f285c273025324538a3d7e419d7f3dde69d0d012103dc1945a85a6147ed5da6d6f150e9de002e40c4ff48cca96b488fe74fa9af8a88', + }, + ], + outputs: [ + { + value: 119595114000n, + scriptPubKey: '76a9144883eb0a391995f422a48595edf7a19af5e0660c88ac', + }, + { + value: 30183921905723n, + scriptPubKey: '76a914a17fdccb11e75bf95df8f760fde346357f34c7ec88ac', + }, + ], +}; + +// UTXO +export const firoUtxo = { + txId: txId, + index: 0, + value: 119595114000n, +}; + +// Firo payment transaction bytes +export const firoPaymentBytes = + '70736274ff0100b30200000001349ef262b9716ba26f5ddf04f9917e3149e16304a8b8b99de6b1e338dee297850200000000ffffffff030000000000000000356a33000000000005f5e10000000000009896802103e5bedab3f782ef17a73e9bdc41ee0e18c3ab477400f35bcf7caa54171db7ff3600ca9a3b0000000017a914d4c141068ab3a242aed5081a27ac3f10ad99ac9887c8e7ee5f030000001976a914872b67c8270a9eaf5c2abf632af3dea989d2e37188ac00000000000100fd1d010200000001349ef262b9716ba26f5ddf04f9917e3149e16304a8b8b99de6b1e338dee29785020000006a47304402207e4cd2745243257f0749b4a41425c2075dfb199f47072bfbf7db14b02677a8ae02204682c5159737314f7c4ba0f7112876497171a7cee48dddf667dccd59cf8ae1280121022b9ed0a9139042921decc62603a4a07357b444da2e0bd6a96c27155117913037ffffffff030000000000000000356a33000000000005f5e10000000000009896802103e5bedab3f782ef17a73e9bdc41ee0e18c3ab477400f35bcf7caa54171db7ff3600ca9a3b0000000017a914d4c141068ab3a242aed5081a27ac3f10ad99ac9887c8e7ee5f030000001976a914872b67c8270a9eaf5c2abf632af3dea989d2e37188ac0000000000000000'; + +// Mock UTXOs for getAddressBoxes +export const mockAddressUtxos = [ + { tx_hash: txId, tx_pos: 0, height: 5693743, value: 1050000000 }, + { tx_hash: '2nd-tx-id', tx_pos: 1, height: 5693740, value: 525000000 }, + { tx_hash: '3rd-tx-id', tx_pos: 0, height: 5693738, value: 200000000 }, +]; + +export const expectedAddressBoxes = [ + { txId: txId, index: 0, value: 1050000000n }, + { txId: '2nd-tx-id', index: 1, value: 525000000n }, +]; + +// Balance response +export const balanceResponse = { confirmed: 1775000000, unconfirmed: 0 }; +export const expectedAddressBalance = 1775000000n; + +// Confirmation +export const expectedTxConfirmation = 4351; + +// Block transaction ids +export const blockTxIds = [ + '7b110de3db716e12de71ca59216a84c985c2f5e5c50ae783d2677d9c0df60658', + '6891a81de933788e1ca8f4735054a86ec4ddf5d768a9dc9057c8d707ea1a9a30', +]; + +// Fee estimation (BTC/kB) +export const estimatedFee = 0.01001657; diff --git a/packages/networks/firo-rpc/tsconfig.build.json b/packages/networks/firo-electrumx/tsconfig.build.json similarity index 100% rename from packages/networks/firo-rpc/tsconfig.build.json rename to packages/networks/firo-electrumx/tsconfig.build.json diff --git a/packages/networks/firo-rpc/tsconfig.json b/packages/networks/firo-electrumx/tsconfig.json similarity index 100% rename from packages/networks/firo-rpc/tsconfig.json rename to packages/networks/firo-electrumx/tsconfig.json diff --git a/packages/networks/firo-rpc/vitest.config.ts b/packages/networks/firo-electrumx/vitest.config.ts similarity index 100% rename from packages/networks/firo-rpc/vitest.config.ts rename to packages/networks/firo-electrumx/vitest.config.ts diff --git a/packages/networks/firo-rpc/lib/firoRpcNetwork.ts b/packages/networks/firo-rpc/lib/firoRpcNetwork.ts deleted file mode 100644 index b51f0ea5b..000000000 --- a/packages/networks/firo-rpc/lib/firoRpcNetwork.ts +++ /dev/null @@ -1,878 +0,0 @@ -import { Psbt } from 'bitcoinjs-lib'; -import { randomBytes } from 'crypto'; - -import { AbstractLogger } from '@rosen-bridge/abstract-logger'; -import JsonBigInt from '@rosen-bridge/json-bigint'; -import { - BlockInfo, - FailedError, - NetworkError, - UnexpectedApiError, - PaymentTransaction, -} from '@rosen-chains/abstract-chain'; -import { - AbstractFiroNetwork, - FiroTx, - FiroUtxo, - CONFIRMATION_TARGET, - FIRO_NETWORK, -} from '@rosen-chains/firo'; -import RateLimitedAxios, { - Axios as RateLimitedAxiosClass, -} from '@rosen-clients/rate-limited-axios'; - -import { - FiroRpcTransaction, - JsonRpcResult, - FiroBlockSummary, - FiroChainInfo, - RpcAuth, - FiroRpcUtxo, -} from './types'; - -class FiroRpcNetwork extends AbstractFiroNetwork { - protected client: RateLimitedAxiosClass; - private getSavedTransactionById: ( - txId: string, - ) => Promise; - - constructor( - url: string, - getSavedTransactionById: ( - txId: string, - ) => Promise, - logger?: AbstractLogger, - auth?: RpcAuth, - ) { - super(logger); - this.getSavedTransactionById = getSavedTransactionById; - - const headers = { 'Content-Type': 'application/json' }; - - // Add API key to headers if provided - if (auth?.apiKey) { - Object.assign(headers, { 'x-api-key': auth.apiKey }); - } - - const authConfig = - auth?.username || auth?.password - ? { - auth: { - username: auth?.username || '', - password: auth?.password || '', - }, - } - : {}; - - this.client = RateLimitedAxios.create({ - baseURL: url, - headers: headers, - ...authConfig, - }); - } - - private generateRandomId = () => randomBytes(32).toString('hex'); - - /** - * Validates that the response ID matches the request ID - * @param requestId the request ID - * @param responseId the response ID - * @throws UnexpectedApiError if IDs don't match - */ - protected validateResponseId = ( - requestId: string, - responseId: string, - ): void => { - if (responseId !== requestId) { - throw new UnexpectedApiError( - `Request and response id are different ['${requestId}' != '${responseId}']`, - ); - } - }; - - /** - * Converts FIRO value to satoshis using string manipulation to avoid floating-point issues - * @param value FIRO value as a number - * @returns satoshis as a bigint - */ - protected convertToSatoshis = (value: number): bigint => { - const parts = value.toString().split('.'); - const part1 = ((parts[1] ?? '') + '0'.repeat(8)).substring(0, 8); - return BigInt((parts[0] === '0' ? '' : parts[0]) + part1); - }; - - /** - * gets the blockchain height - * @returns the blockchain height - */ - getHeight = async (): Promise => { - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'getblockchaininfo', - id: randomId, - params: [], - }); - - this.validateResponseId(randomId, response.data.id); - - const chainInfo: FiroChainInfo = response.data.result; - this.logger.debug( - `Requested 'getblockchaininfo'. Response: ${JsonBigInt.stringify( - chainInfo, - )}`, - ); - - return chainInfo.blocks; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to fetch current height from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + `${JsonBigInt.stringify(e.response.data)}`, - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets id of all transactions in the given block - * @param blockId the block id - * @returns list of the transaction ids in the block - */ - getBlockTransactionIds = async (blockId: string): Promise> => { - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'getblock', - id: randomId, - params: [blockId, true], - }); - - this.validateResponseId(randomId, response.data.id); - - const blockData: FiroBlockSummary = response.data.result; - this.logger.debug( - `Requested 'getblock' for blockId [${blockId}]. Response: ${JsonBigInt.stringify( - blockData, - )}`, - ); - - return blockData.tx; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get block [${blockId}] transaction ids from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets info of the given block - * @param blockId the block id - * @returns the block info - */ - getBlockInfo = async (blockId: string): Promise => { - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'getblock', - id: randomId, - params: [blockId, true], - }); - - this.validateResponseId(randomId, response.data.id); - - const blockData: FiroBlockSummary = response.data.result; - this.logger.debug( - `Requested 'getblock' for blockId [${blockId}]. Response: ${JsonBigInt.stringify( - blockData, - )}`, - ); - - return { - hash: blockData.hash, - parentHash: blockData.previousblockhash, - height: blockData.height, - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get block [${blockId}] info from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets a transaction - * @param transactionId the transaction id - * @param blockId the block id - * @returns the transaction - */ - getTransaction = async ( - transactionId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - blockId: string, - ): Promise => { - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'getrawtransaction', - id: randomId, - params: [transactionId, true], - }); - - this.validateResponseId(randomId, response.data.id); - - const tx: FiroRpcTransaction = response.data.result; - this.logger.debug( - `Requested 'getrawtransaction' for txId [${transactionId}]. Response: ${JsonBigInt.stringify( - tx, - )}`, - ); - - // Transform the RPC transaction to the expected FiroTx format - const firoTx: FiroTx = { - id: tx.txid, - inputs: tx.vin.map((input) => ({ - txId: input.txid || '', // Coinbase txs don't have txid - index: input.vout ?? -1, // Coinbase txs don't have vout - scriptPubKey: input.scriptSig?.hex || input.coinbase || '', // Coinbase uses coinbase field - })), - outputs: tx.vout.map((output) => ({ - value: this.convertToSatoshis(output.value), - scriptPubKey: output.scriptPubKey.hex, - })), - }; - - return firoTx; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get transaction [${transactionId}] from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * submits a transaction - * @param transaction the transaction - */ - submitTransaction = async (transaction: Psbt): Promise => { - // Extract the raw transaction hex - const txHex = transaction.extractTransaction(true).toHex(); - - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'sendrawtransaction', - id: randomId, - params: [txHex], - }); - - this.validateResponseId(randomId, response.data.id); - - this.logger.debug( - `Submitted transaction. Response: ${JsonBigInt.stringify( - response.data, - )}`, - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to submit transaction to Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * checks if a box is unspent and valid - * @param boxId the box id - * @returns true if the box is unspent and valid - */ - isBoxUnspentAndValid = async (boxId: string): Promise => { - const [txId, outputIndexStr] = boxId.split('.'); - const outputIndex = parseInt(outputIndexStr); - - const randomId = this.generateRandomId(); - try { - // Check if the output is spent - const listUnspentResponse = await this.client.post('', { - method: 'gettxout', - id: randomId, - params: [txId, outputIndex, false], // txid, n, include_mempool - }); - - this.validateResponseId(randomId, listUnspentResponse.data.id); - - // If the result is null, the output is spent - return listUnspentResponse.data.result !== null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to check if box [${boxId}] is unspent from Firo RPC: `; - if (e.response && e.response.data && e.response.data.error) { - if (e.response.data.error.code === -5) { - // No such transaction error - return false; - } - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets a utxo - * @param boxId the box id - * @returns the utxo - */ - getUtxo = async (boxId: string): Promise => { - const [txId, outputIndexStr] = boxId.split('.'); - const outputIndex = parseInt(outputIndexStr); - - const randomId = this.generateRandomId(); - try { - // Get the transaction to extract the UTXO information - const txResponse = await this.client.post('', { - method: 'getrawtransaction', - id: randomId, - params: [txId, true], - }); - - this.validateResponseId(randomId, txResponse.data.id); - - const tx: FiroRpcTransaction = txResponse.data.result; - - if (!tx || outputIndex >= tx.vout.length) { - throw new FailedError(`UTXO with boxId [${boxId}] not found`); - } - - const output = tx.vout[outputIndex]; - - return { - txId: txId, - index: outputIndex, - value: this.convertToSatoshis(output.value), - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get UTXO [${boxId}] from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets the fee ratio - * @returns the fee ratio in satoshis/byte - */ - getFeeRatio = async (): Promise => { - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'estimatesmartfee', - id: randomId, - params: [CONFIRMATION_TARGET], // Number of blocks to target for confirmation - }); - - this.validateResponseId(randomId, response.data.id); - const feeSatoshis = this.convertToSatoshis(response.data.result.feerate); - const feeRate = Number(feeSatoshis) / 1000; - - this.logger.debug( - `Requested 'estimatesmartfee'. Response: ${JsonBigInt.stringify( - response.data.result, - )}`, - ); - return Math.ceil(feeRate); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get fee ratio from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * checks if a transaction is in the mempool - * @param txId the transaction id - * @returns true if the transaction is in the mempool - */ - isTxInMempool = async (txId: string): Promise => { - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'getmempoolentry', - id: randomId, - params: [txId], - }); - - this.validateResponseId(randomId, response.data.id); - - // If we get a successful response, the transaction is in the mempool - return true; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - // If we get a specific error indicating the tx is not in the mempool - if (e.response && e.response.data && e.response.data.error) { - if (e.response.data.error.code === -5) { - // Transaction not in mempool - return false; - } - } - - const baseError = `Failed to check if tx [${txId}] is in mempool from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets transaction hex - * @param txId the transaction id - * @returns the transaction hex - */ - getTransactionHex = async (txId: string): Promise => { - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'getrawtransaction', - id: randomId, - params: [txId, false], // txid, verbose (false = return hex) - }); - - this.validateResponseId(randomId, response.data.id); - - const txHex: string = response.data.result; - this.logger.debug(`Requested transaction hex for txId [${txId}].`); - - return txHex; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get transaction hex [${txId}] from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets confirmed and unspent UTXOs of an address - * @param address the address - * @param offset offset for pagination - * @param limit maximum number of UTXOs to return - * @returns list of UTXOs - */ - getAddressBoxes = async ( - address: string, - offset: number, - limit: number, - ): Promise> => { - const randomId = this.generateRandomId(); - try { - // Get list of unspent outputs for the address - const response = await this.client.post('', { - method: 'getaddressutxos', - id: randomId, - params: [{ addresses: [address] }], - }); - - this.validateResponseId(randomId, response.data.id); - - const utxos: Array = response.data.result; - - this.logger.debug( - `Requested 'getaddressutxos' for address [${address}]. Response: ${JsonBigInt.stringify( - utxos, - )}`, - ); - - // Filter out unconfirmed UTXOs (height=0), then convert + paginate - const firoUtxos = utxos - .filter((utxo) => utxo.height > 0) - .slice(offset, offset + limit) - .map((utxo) => ({ - txId: utxo.txid, - index: utxo.outputIndex, - value: BigInt(utxo.satoshis), - })); - - this.logger.debug( - `Requested 'getaddressutxos' for address [${address}]. Found ${utxos.length} UTXOs, returning ${firoUtxos.length}.`, - ); - - return firoUtxos; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get address boxes for [${address}] from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets the number of confirmations for a transaction (only supports signed tx id) - * @param transactionId the signed transaction id - * @returns the number of confirmations - */ - protected getTxConfirmationSigned = async ( - transactionId: string, - ): Promise => { - const randomId = this.generateRandomId(); - try { - const response = await this.client.post('', { - method: 'getrawtransaction', - id: randomId, - params: [transactionId, true], - }); - - this.validateResponseId(randomId, response.data.id); - - if (!response.data.result) { - this.logger.debug(`tx [${transactionId}] is not found`); - return -1; - } - - const tx: FiroRpcTransaction = response.data.result; - - this.logger.debug( - `Requested 'getrawtransaction' for txId [${transactionId}]. Response: ${JsonBigInt.stringify( - tx, - )}`, - ); - - // Return -1 for unconfirmed transactions (confirmations undefined or 0) - return tx.confirmations && tx.confirmations > 0 ? tx.confirmations : -1; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get transaction confirmations for [${transactionId}] from Firo RPC: `; - if (e.response && e.response.data && e.response.data.error) { - if (e.response.data.error.code === -5) { - // Transaction not found - this.logger.debug(`tx [${transactionId}] is not found`); - return -1; - } - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * gets confirmation for a transaction (returns -1 if tx is not mined or found) - * @param transactionId the transaction id (supports both real signed tx id and unsigned tx id) - * @returns the transaction confirmation (returns -1 if tx is not mined or found) - */ - getTxConfirmation = async (transactionId: string): Promise => { - const realTxId = await this.getActualTxId(transactionId); - return await this.getTxConfirmationSigned(realTxId); - }; - - /** - * gets the balance of native assets for an address - * @param address the address - * @returns the asset balance (only native token, Firo doesn't support tokens) - */ - getAddressAssets = async ( - address: string, - ): Promise<{ - nativeToken: bigint; - tokens: Array<{ id: string; value: bigint }>; - }> => { - const randomId = this.generateRandomId(); - try { - // Get list of unspent outputs for the address - const response = await this.client.post('', { - method: 'getaddressutxos', - id: randomId, - params: [{ addresses: [address] }], - }); - - this.validateResponseId(randomId, response.data.id); - - const utxos: Array = response.data.result; - - this.logger.debug( - `Requested 'getaddressutxos' for address [${address}]. Response: ${JsonBigInt.stringify( - utxos, - )}`, - ); - - // Sum up all confirmed UTXO values - const totalSatoshis = utxos - .filter((utxo) => utxo.height > 0) - .reduce((sum, utxo) => sum + BigInt(utxo.satoshis), 0n); - - return { - nativeToken: totalSatoshis, - tokens: [], // Firo doesn't support tokens - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get address assets for [${address}] from Firo RPC: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - }; - - /** - * Attempts to find which transaction spent a specific UTXO using getspentinfo RPC - * @param index the output index that was spent - * @param txId the transaction ID containing the output - * @returns the transaction that spent the UTXO, or undefined if not found - */ - protected getSpentTransactionByInputId = async ( - index: number, - txId: string, - ): Promise => { - try { - const randomId = this.generateRandomId(); - const response = await this.client.post('', { - method: 'getspentinfo', - id: randomId, - params: [{ txid: txId, index }], - }); - - this.validateResponseId(randomId, response.data.id); - - const spentInfo = response.data.result; - this.logger.debug( - `Requested 'getspentinfo' for utxoId [${txId}.${index}]. Response: ${JsonBigInt.stringify( - spentInfo, - )}`, - ); - if (!spentInfo || !spentInfo.txid) { - return undefined; - } - - return this.getTransaction(spentInfo.txid, ''); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - if (error.response?.data?.error?.code === -5) { - this.logger.debug( - `UTXO ${txId}:${index} is not spent (getspentinfo returned not-found)`, - ); - } else { - this.logger.warn( - `Unexpected error looking up spending transaction for UTXO ${txId}:${index}: ${error}`, - ); - } - return undefined; - } - }; - - /** - * Attempts to extract signed transaction ID directly from PSBT - * @param psbt the PSBT containing the transaction - * @returns the signed transaction ID, or undefined if extraction fails - */ - protected extractActualTxIdFromPsbt = async ( - psbt: Psbt, - ): Promise => { - try { - return psbt.extractTransaction(true).getId(); - } catch (error) { - this.logger.debug( - `Failed to extract signed transaction ID from PSBT: ${error}`, - ); - return undefined; - } - }; - - /** - * Attempts to find signed transaction ID using RPC lookup of spending transactions - * @param psbt the PSBT containing the unsigned transaction - * @returns the signed transaction ID, or undefined if not found - */ - protected extractActualTxIdWithRpcLookup = async ( - psbt: Psbt, - ): Promise => { - try { - if (psbt.txInputs.length === 0) { - return undefined; - } - - // Use the first input to find the spending transaction - const firstInput = psbt.txInputs[0]; - const inputTxId = Buffer.from(firstInput.hash).reverse().toString('hex'); - const inputIndex = firstInput.index; - - const spentTx = await this.getSpentTransactionByInputId( - inputIndex, - inputTxId, - ); - if (!spentTx) { - return undefined; - } - - // Verify this is the same transaction by comparing inputs and outputs - const sameInputs = psbt.txInputs.every( - (input, i) => - spentTx.inputs[i]?.txId === - Buffer.from(input.hash).reverse().toString('hex') && - spentTx.inputs[i]?.index === input.index, - ); - - const sameOutputs = psbt.txOutputs.every( - (output, i) => - spentTx.outputs[i]?.scriptPubKey === - Buffer.from(output.script).toString('hex') && - spentTx.outputs[i]?.value === BigInt(output.value), - ); - - if (sameInputs && sameOutputs) { - return spentTx.id; - } - - return undefined; - } catch (error) { - this.logger.debug( - `Failed to find signed transaction ID using RPC lookup: ${error}`, - ); - return undefined; - } - }; - - /** - * gets the actual transaction ID from a transaction hash - * For unsigned transactions, finds the corresponding signed transaction ID - * - * Uses a two-stage approach: - * 1. First attempts direct extraction from PSBT - * 2. Falls back to RPC-based lookup using Firo's address indexing - * - * @param hash the transaction hash (can be unsigned or signed) - * @returns the actual signed transaction ID - */ - getActualTxId = async (hash: string): Promise => { - let actualTxId = hash; - try { - const realPaymentTx = await this.getSavedTransactionById(hash); - - if (realPaymentTx) { - const realTx = Psbt.fromBuffer(Buffer.from(realPaymentTx.txBytes), { - network: FIRO_NETWORK, - }); - - // Method 1: Try direct PSBT extraction - const directExtraction = await this.extractActualTxIdFromPsbt(realTx); - if (directExtraction) { - actualTxId = directExtraction; - } else { - // Method 2: Fallback to RPC lookup - this.logger.debug( - `Direct PSBT extraction failed for hash [${hash}], attempting RPC lookup...`, - ); - - const rpcLookup = await this.extractActualTxIdWithRpcLookup(realTx); - if (rpcLookup) { - actualTxId = rpcLookup; - } else { - this.logger.debug( - `Both extraction methods failed for hash [${hash}], using original hash as fallback`, - ); - } - } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - const baseError = `Failed to get actual txId for tx [${hash}] which was found in the database: `; - if (e.response) { - throw new FailedError( - baseError + JsonBigInt.stringify(e.response.data), - ); - } else if (e.request) { - throw new NetworkError(baseError + e.message); - } else { - throw new UnexpectedApiError(baseError + e.message); - } - } - - return actualTxId; - }; -} - -export default FiroRpcNetwork; diff --git a/packages/networks/firo-rpc/lib/index.ts b/packages/networks/firo-rpc/lib/index.ts deleted file mode 100644 index 6250bbaf5..000000000 --- a/packages/networks/firo-rpc/lib/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import FiroRpcNetwork from './firoRpcNetwork'; -import { RpcAuth } from './types'; - -export { FiroRpcNetwork, RpcAuth }; diff --git a/packages/networks/firo-rpc/lib/types.ts b/packages/networks/firo-rpc/lib/types.ts deleted file mode 100644 index 2566f636c..000000000 --- a/packages/networks/firo-rpc/lib/types.ts +++ /dev/null @@ -1,102 +0,0 @@ -export interface FiroBlockSummary { - hash: string; - height: number; - time: number; - previousblockhash: string; - tx: Array; - confirmations: number; - difficulty: number; - merkleroot: string; - nonce: number; - size: number; - weight: number; - version: number; - versionHex: string; - chainwork: string; - bits: string; - mediantime: number; - nextblockhash?: string; -} - -export interface FiroRpcTxInput { - txid?: string; // Not present in coinbase transactions - vout?: number; // Not present in coinbase transactions - scriptSig?: { - // Not present in coinbase transactions - asm: string; - hex: string; - }; - sequence: number; - coinbase?: string; // Only present in coinbase transactions - txinwitness?: string[]; -} - -export interface FiroRpcTxOutput { - value: number; - n: number; - scriptPubKey: { - asm: string; - hex: string; - type: string; - addresses?: string[]; - reqSigs?: number; - }; -} - -export interface FiroRpcTransaction { - txid: string; - hash: string; - version: number; - size: number; - vsize: number; - weight: number; - locktime: number; - vin: Array; - vout: Array; - hex?: string; - blockhash?: string; - confirmations?: number; - time?: number; - blocktime?: number; - blockheight?: number; -} - -export interface JsonRpcResult { - result: any; // eslint-disable-line @typescript-eslint/no-explicit-any - error: any; // eslint-disable-line @typescript-eslint/no-explicit-any - id: string; -} - -export interface FiroChainInfo { - chain: string; - blocks: number; - headers: number; - bestblockhash: string; - difficulty: number; - mediantime: number; - verificationprogress: number; - initialblockdownload: boolean; - chainwork: string; - size_on_disk: number; - pruned: boolean; - softforks: Record; // eslint-disable-line @typescript-eslint/no-explicit-any - warnings: string; -} - -export interface FiroRpcUtxo { - address: string; - txid: string; - outputIndex: number; - script: string; - satoshis: number; - height: number; -} - -/** - * Authentication credentials for Firocoin RPC - */ -export interface RpcAuth { - username?: string; - password?: string; - apiKey?: string; -} diff --git a/packages/networks/firo-rpc/tests/firoRpcNetwork.spec.ts b/packages/networks/firo-rpc/tests/firoRpcNetwork.spec.ts deleted file mode 100644 index 5fe05dacb..000000000 --- a/packages/networks/firo-rpc/tests/firoRpcNetwork.spec.ts +++ /dev/null @@ -1,982 +0,0 @@ -import { vi, describe, it, expect, beforeEach } from 'vitest'; - -import { - FailedError, - NetworkError, - PaymentTransaction, - TransactionType, -} from '@rosen-chains/abstract-chain'; - -import FiroRpcNetwork from '../lib/firoRpcNetwork'; -import { resetAxiosMock, axiosInstance } from './mocked/rateLimitedAxios.mock'; -import * as testData from './testData'; - -describe('FiroRpcNetwork', () => { - const URL = 'firo-rpc-url'; - const mockGetSavedTransactionById = vi.fn().mockReturnValue(undefined); - - beforeEach(() => { - resetAxiosMock(); - }); - - describe('getHeight', () => { - /** - * @target `FiroRpcNetwork.getHeight` should return block height successfully - * @dependencies - * @scenario - * - mock axios to return blockchain info - * - run test - * - check returned value - * @expected - * - it should be mocked block height - */ - it('should return block height successfully', async () => { - // Set up the mock for this specific test - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.blockHeightResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getHeight(); - - expect(result).toEqual(testData.blockHeightResponse.result.blocks); - }); - - /** - * @target `FiroRpcNetwork.getHeight` should throw FailedError when API returns error - * @dependencies - * @scenario - * - mock axios to return error response - * - run test - * @expected - * - it should throw FailedError - */ - it('should throw FailedError when API returns error', async () => { - axiosInstance.post.mockRejectedValueOnce({ - response: { - data: { - error: 'Server error', - }, - }, - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - await expect(network.getHeight()).rejects.toThrow(FailedError); - }); - - /** - * @target `FiroRpcNetwork.getHeight` should throw NetworkError when network error occurs - * @dependencies - * @scenario - * - mock axios to throw network error - * - run test - * @expected - * - it should throw NetworkError - */ - it('should throw NetworkError when network error occurs', async () => { - axiosInstance.post.mockRejectedValueOnce({ - request: {}, - message: 'Network error', - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - await expect(network.getHeight()).rejects.toThrow(NetworkError); - }); - }); - - describe('getBlockTransactionIds', () => { - /** - * @target `FiroRpcNetwork.getBlockTransactionIds` should return block tx ids successfully - * @dependencies - * @scenario - * - mock axios to return block data - * - run test - * - check returned value - * @expected - * - it should be mocked tx ids - */ - it('should return block tx ids successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.blockResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getBlockTransactionIds(testData.blockHash); - - expect(result).toEqual(testData.blockResponse.result.tx); - }); - }); - - describe('getBlockInfo', () => { - /** - * @target `FiroRpcNetwork.getBlockInfo` should return block info successfully - * @dependencies - * @scenario - * - mock axios to return block data - * - run test - * - check returned value - * @expected - * - it should match mocked block info - */ - it('should return block info successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.blockResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getBlockInfo(testData.blockHash); - - expect(result).toEqual(testData.blockInfo); - }); - }); - - describe('getTransaction', () => { - /** - * @target `FiroRpcNetwork.getTransaction` should return transaction successfully - * @dependencies - * @scenario - * - mock axios to return transaction data - * - run test - * - check returned value - * @expected - * - it should match expected transaction format - */ - it('should return transaction successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.txResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getTransaction( - testData.txId, - testData.txBlockHash, - ); - - expect(result).toEqual(testData.firoTx); - }); - }); - - describe('isBoxUnspentAndValid', () => { - /** - * @target `FiroRpcNetwork.isBoxUnspentAndValid` should return true for unspent output - * @dependencies - * @scenario - * - mock axios to return UTXO data - * - run test - * - check returned value - * @expected - * - it should return true - */ - it('should return true for unspent output', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.txOutResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.isBoxUnspentAndValid(`${testData.txId}.0`); - - expect(result).toEqual(true); - }); - - /** - * @target `FiroRpcNetwork.isBoxUnspentAndValid` should return false for spent output - * @dependencies - * @scenario - * - mock axios to return null for spent output - * - run test - * - check returned value - * @expected - * - it should return false - */ - it('should return false for spent output', async () => { - axiosInstance.post.mockImplementation((url, data) => { - return Promise.resolve({ - data: { - result: null, - error: null, - id: data.id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.isBoxUnspentAndValid(`${testData.txId}.0`); - - expect(result).toEqual(false); - }); - - /** - * @target `FiroRpcNetwork.isBoxUnspentAndValid` should return false when transaction doesn't exist - * @dependencies - * @scenario - * - mock axios to throw error with code -5 (no such transaction) - * - run test - * - check returned value - * @expected - * - it should return false - */ - it("should return false when transaction doesn't exist", async () => { - axiosInstance.post.mockRejectedValueOnce({ - response: { - data: { - error: { - code: -5, - message: 'No such transaction', - }, - }, - }, - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.isBoxUnspentAndValid(`${testData.txId}.0`); - - expect(result).toEqual(false); - }); - }); - - describe('getUtxo', () => { - /** - * @target `FiroRpcNetwork.getUtxo` should return UTXO data successfully - * @dependencies - * @scenario - * - mock axios for transaction and UTXO check - * - run test - * - check returned value - * @expected - * - it should match expected UTXO format - */ - it('should return UTXO data successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { method, id } = data; - - if (method === 'getrawtransaction') { - return Promise.resolve({ - data: { - ...testData.txResponse, - id: id, - }, - }); - } else if (method === 'gettxout') { - return Promise.resolve({ - data: { - ...testData.txOutResponse, - id: id, - }, - }); - } - - return Promise.reject(new Error(`Unexpected method: ${method}`)); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getUtxo(`${testData.txId}.0`); - - expect(result.txId).toEqual(testData.txId); - expect(result.index).toEqual(0); - expect(result.value).toEqual(testData.firoUtxo.value); - }); - }); - - describe('getFeeRatio', () => { - /** - * @target `FiroRpcNetwork.getFeeRatio` should return fee ratio successfully - * @dependencies - * @scenario - * - mock axios to return fee estimation - * - run test - * - check returned value - * @expected - * - it should return calculated fee ratio (satoshis/byte) - */ - it('should return fee ratio successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.estimateSmartFeeResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getFeeRatio(); - - // Convert FIRO/kB to satoshis/byte - const expectedFeeRate = Math.ceil( - (testData.estimateSmartFeeResponse.result.feerate * 100000000) / 1000, - ); - expect(result).toEqual(expectedFeeRate); - }); - }); - - describe('isTxInMempool', () => { - /** - * @target `FiroRpcNetwork.isTxInMempool` should return true when tx is in mempool - * @dependencies - * @scenario - * - mock axios to return success for mempool query - * - run test - * - check returned value - * @expected - * - it should return true - */ - it('should return true when tx is in mempool', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - result: { fees: 0.1 }, - error: null, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.isTxInMempool(testData.txId); - - expect(result).toEqual(true); - }); - - /** - * @target `FiroRpcNetwork.isTxInMempool` should return false when tx is not in mempool - * @dependencies - * @scenario - * - mock axios to return error for tx not in mempool - * - run test - * - check returned value - * @expected - * - it should return false - */ - it('should return false when tx is not in mempool', async () => { - axiosInstance.post.mockRejectedValueOnce({ - response: { - data: { - ...testData.txNotInMempoolResponse, - }, - }, - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.isTxInMempool(testData.txId); - - expect(result).toEqual(false); - }); - }); - - describe('getTransactionHex', () => { - /** - * @target `FiroRpcNetwork.getTransactionHex` should return transaction hex successfully - * @dependencies - * @scenario - * - mock axios to return transaction hex - * - run test - * - check returned value - * @expected - * - it should match expected transaction hex - */ - it('should return transaction hex successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.txHexResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getTransactionHex(testData.txId); - - expect(result).toEqual(testData.txHexResponse.result); - }); - }); - - describe('submitTransaction', () => { - /** - * @target `FiroRpcNetwork.submitTransaction` should submit transaction successfully - * @dependencies - * @scenario - * - mock Psbt for transaction extraction - * - mock axios response for submission - * - run test - * @expected - * - it should not throw error - */ - it('should submit transaction successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - result: testData.txId, - error: null, - id: id, - }, - }); - }); - - // Create a mock PSBT - const mockPsbt = { - finalizeAllInputs: vi.fn(), - extractTransaction: vi.fn().mockReturnValue({ - toHex: vi.fn().mockReturnValue('01000000...'), - }), - }; - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - // This should not throw an error - await expect( - network.submitTransaction(mockPsbt as any), // eslint-disable-line @typescript-eslint/no-explicit-any - ).resolves.not.toThrow(); - }); - }); - - describe('getAddressBoxes', () => { - /** - * @target `FiroRpcNetwork.getAddressBoxes` should return address UTXOs successfully with pagination - * @dependencies - * @scenario - * - mock axios to return getaddressutxos data - * - run test with pagination parameters - * - check returned value - * @expected - * - it should return paginated UTXOs in correct format - */ - it('should return address UTXOs successfully with pagination', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - result: testData.mockAddressUtxos, - error: null, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - // Get first 2 UTXOs (offset 0, limit 2) - const result = await network.getAddressBoxes(testData.lockAddress, 0, 2); - - expect(result).toEqual(testData.expectedAddressBoxes); - }); - - /** - * @target `FiroRpcNetwork.getAddressBoxes` should handle empty address - * @dependencies - * @scenario - * - mock axios to return empty UTXO list - * - run test - * - check returned value - * @expected - * - it should return empty array - */ - it('should handle empty address', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - result: [], - error: null, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getAddressBoxes('empty-address', 0, 10); - - expect(result).toEqual([]); - }); - }); - - describe('getTxConfirmation', () => { - /** - * @target `FiroRpcNetwork.getTxConfirmation` should return confirmation count successfully - * @dependencies - * @scenario - * - mock axios to return transaction with confirmations - * - run test - * - check returned value - * @expected - * - it should return correct number of confirmations - */ - it('should return confirmation count successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.txResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getTxConfirmation(testData.txId); - - expect(result).toEqual(testData.expectedTxConfirmation); - }); - - /** - * @target `FiroRpcNetwork.getTxConfirmation` should return -1 for unconfirmed tx - * @dependencies - * @scenario - * - mock axios to return transaction without confirmations field - * - run test - * - check returned value - * @expected - * - it should return -1 - */ - it('should return -1 for unconfirmed transaction', async () => { - const unconfirmedTxResponse = { - result: { - ...testData.txResponse.result, - confirmations: undefined, - }, - error: null, - id: 'tx_request', - }; - - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...unconfirmedTxResponse, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getTxConfirmation(testData.txId); - - expect(result).toEqual(-1); - }); - - /** - * @target `FiroRpcNetwork.getTxConfirmation` should return -1 when transaction is not found - * @dependencies - * @scenario - * - mock axios to return error code -5 for non-existent tx - * - run test - * - check returned value - * @expected - * - it should return -1 - */ - it('should return -1 when transaction is not found', async () => { - axiosInstance.post.mockRejectedValueOnce({ - response: { - data: { - result: null, - error: { - code: -5, - message: 'No such transaction', - }, - }, - }, - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getTxConfirmation('nonexistent-tx-id'); - - expect(result).toEqual(-1); - }); - - /** - * @target `FiroRpcNetwork.getTxConfirmation` should return -1 when RPC resolves with result null - * @dependencies - * @scenario - * - mock axios to return a successful response with result: null - * - run test - * - check returned value - * @expected - * - it should return -1 - */ - it('should return -1 when RPC resolves with result null', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - result: null, - error: null, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getTxConfirmation('nonexistent-tx-id'); - - expect(result).toEqual(-1); - }); - - /** - * @target `FiroRpcNetwork.getTxConfirmation` should fetch confirmation using unsigned hash successfully - * @dependencies - * @scenario - * - create a custom getSavedTransactionById that returns a payment transaction - * - mock extraction methods to resolve the unsigned hash to a signed hash - * - mock axios to return transaction with confirmations - * - run test - * - check returned value - * @expected - * - it should resolve the unsigned hash and return the correct confirmation count - */ - it('should fetch confirmation using unsigned hash successfully', async () => { - const firoPayment = new PaymentTransaction( - 'firo', - testData.unsignedTxId, - 'eventId', - Buffer.from(testData.firoPaymentBytes, 'hex'), - TransactionType.payment, - ); - - const customNetwork = new FiroRpcNetwork(URL, async (txId: string) => { - if (txId === testData.unsignedTxId) { - return firoPayment; - } - return undefined; - }); - - const getTxConfirmationSignedSpy = vi.spyOn( - customNetwork as any, // eslint-disable-line @typescript-eslint/no-explicit-any - 'getTxConfirmationSigned', - ); - - // Mock direct extraction to fail, RPC lookup to succeed - vi.spyOn( - customNetwork as any, // eslint-disable-line @typescript-eslint/no-explicit-any - 'extractActualTxIdFromPsbt', - ).mockResolvedValue(undefined); - vi.spyOn( - customNetwork as any, // eslint-disable-line @typescript-eslint/no-explicit-any - 'extractActualTxIdWithRpcLookup', - ).mockResolvedValue(testData.firoTx.id); - - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - ...testData.txResponse, - id: id, - }, - }); - }); - - const result = await customNetwork.getTxConfirmation( - testData.unsignedTxId, - ); - - expect(getTxConfirmationSignedSpy).toHaveBeenCalledExactlyOnceWith( - testData.firoTx.id, - ); - expect(result).toEqual(testData.expectedTxConfirmation); - }); - }); - - describe('getAddressAssets', () => { - /** - * @target `FiroRpcNetwork.getAddressAssets` should return address balance successfully - * @dependencies - * @scenario - * - mock axios to return getaddressutxos with multiple UTXOs - * - run test - * - check returned value - * @expected - * - it should return sum of all UTXOs as native token balance - */ - it('should return address balance successfully', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - result: testData.mockAddressUtxos, - error: null, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getAddressAssets(testData.lockAddress); - - expect(result.nativeToken).toEqual(testData.expectedAddressBalance); - expect(result.tokens).toEqual([]); // Firo doesn't support tokens - expect(axiosInstance.post).toHaveBeenCalledWith( - '', - expect.objectContaining({ - method: 'getaddressutxos', - params: [{ addresses: [testData.lockAddress] }], - }), - ); - }); - - /** - * @target `FiroRpcNetwork.getAddressAssets` should return 0 for empty address - * @dependencies - * @scenario - * - mock axios to return empty UTXO list - * - run test - * - check returned value - * @expected - * - it should return 0 native token balance - */ - it('should return 0 for empty address', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { id } = data; - return Promise.resolve({ - data: { - result: [], - error: null, - id: id, - }, - }); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await network.getAddressAssets('empty-address'); - - expect(result.nativeToken).toEqual(0n); - expect(result.tokens).toEqual([]); - }); - }); - - describe('getSpentTransactionByInputId', () => { - /** - * @target `FiroRpcNetwork.getSpentTransactionByInputId` should return the spent info for a spent utxo - * @dependencies - * @scenario - * - mock axios to return getspentinfo and getrawtransaction data - * - run test - * - check returned value - * @expected - * - it should return the spending transaction - */ - it('should return the spent info for a spent utxo', async () => { - axiosInstance.post.mockImplementation((url, data) => { - const { method, id } = data; - - if (method === 'getspentinfo') { - return Promise.resolve({ - data: { - ...testData.spentInfoResponse, - id: id, - }, - }); - } else if (method === 'getrawtransaction') { - return Promise.resolve({ - data: { - ...testData.txResponse, - id: id, - }, - }); - } - - return Promise.reject(new Error(`Unexpected method: ${method}`)); - }); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await (network as any) // eslint-disable-line @typescript-eslint/no-explicit-any - .getSpentTransactionByInputId(0, testData.txId); - - expect(result).toEqual(testData.firoTx); - }); - - /** - * @target `FiroRpcNetwork.getSpentTransactionByInputId` should return undefined for an unspent utxo - * @dependencies - * @scenario - * - mock axios to return error for getspentinfo - * - run test - * - check returned value - * @expected - * - it should return undefined - */ - it('should return undefined for an unspent utxo', async () => { - axiosInstance.post.mockRejectedValueOnce(testData.unspentInfoError); - - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const result = await (network as any) // eslint-disable-line @typescript-eslint/no-explicit-any - .getSpentTransactionByInputId(0, testData.txId); - - expect(result).toBeUndefined(); - }); - }); - - describe('getActualTxId', () => { - /** - * @target `FiroRpcNetwork.getActualTxId` should return the same hash when no saved transaction exists - * @dependencies - * @scenario - * - call getActualTxId with a transaction hash - * - check returned value - * @expected - * - it should return the same hash when transaction is not in database - */ - it('should return the same hash when no saved transaction exists', async () => { - const network = new FiroRpcNetwork(URL, mockGetSavedTransactionById); - const hash = testData.txId; - const result = await network.getActualTxId(hash); - - expect(result).toEqual(hash); - }); - - /** - * @target `FiroRpcNetwork.getActualTxId` should extract signed txId from PSBT (method 1) - * @dependencies - * @scenario - * - create a custom getSavedTransactionById that returns a payment transaction - * - mock the direct PSBT extraction to succeed - * - call getActualTxId with the unsigned hash - * @expected - * - it should use the direct extraction method and return signed ID - */ - it('should extract signed txId from PSBT using direct method', async () => { - const firoPayment = new PaymentTransaction( - 'firo', - testData.unsignedTxId, - 'eventId', - Buffer.from(testData.firoPaymentBytes, 'hex'), - TransactionType.payment, - ); - - const customNetwork = new FiroRpcNetwork(URL, async (txId: string) => { - if (txId === testData.unsignedTxId) { - return firoPayment; - } - return undefined; - }); - - // Mock the direct extraction method to succeed - const extractDirectSpy = vi - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(customNetwork as any, 'extractActualTxIdFromPsbt') - .mockResolvedValue(testData.txId); - - const result = await customNetwork.getActualTxId(testData.unsignedTxId); - - expect(extractDirectSpy).toHaveBeenCalled(); - expect(result).toEqual(testData.txId); - }); - - /** - * @target `FiroRpcNetwork.getActualTxId` should fallback to RPC lookup when direct extraction fails - * @dependencies - * @scenario - * - create a custom getSavedTransactionById that returns a payment transaction - * - mock direct extraction to fail - * - mock RPC lookup to succeed - * @expected - * - it should fallback to RPC lookup method and return signed ID - */ - it('should fallback to RPC lookup when direct extraction fails', async () => { - const firoPayment = new PaymentTransaction( - 'firo', - testData.unsignedTxId, - 'eventId', - Buffer.from(testData.firoPaymentBytes, 'hex'), - TransactionType.payment, - ); - - const customNetwork = new FiroRpcNetwork(URL, async (txId: string) => { - if (txId === testData.unsignedTxId) { - return firoPayment; - } - return undefined; - }); - - // Mock direct extraction to fail - const extractDirectSpy = vi - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(customNetwork as any, 'extractActualTxIdFromPsbt') - .mockResolvedValue(undefined); - - // Mock RPC lookup to succeed - const extractRpcSpy = vi - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .spyOn(customNetwork as any, 'extractActualTxIdWithRpcLookup') - .mockResolvedValue(testData.txId); - - const result = await customNetwork.getActualTxId(testData.unsignedTxId); - - expect(extractDirectSpy).toHaveBeenCalled(); - expect(extractRpcSpy).toHaveBeenCalled(); - expect(result).toEqual(testData.txId); - }); - - /** - * @target `FiroRpcNetwork.getActualTxId` should return original hash when both methods fail - * @dependencies - * @scenario - * - create a custom getSavedTransactionById that returns a payment transaction - * - mock both extraction methods to fail - * @expected - * - it should return the original hash as fallback - */ - it('should return original hash when both extraction methods fail', async () => { - const firoPayment = new PaymentTransaction( - 'firo', - testData.unsignedTxId, - 'eventId', - Buffer.from(testData.firoPaymentBytes, 'hex'), - TransactionType.payment, - ); - - const customNetwork = new FiroRpcNetwork(URL, async (txId: string) => { - if (txId === testData.unsignedTxId) { - return firoPayment; - } - return undefined; - }); - - // Mock both methods to fail - vi.spyOn( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - customNetwork as any, - 'extractActualTxIdFromPsbt', - ).mockResolvedValue(undefined); - vi.spyOn( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - customNetwork as any, - 'extractActualTxIdWithRpcLookup', - ).mockResolvedValue(undefined); - - const result = await customNetwork.getActualTxId(testData.unsignedTxId); - - expect(result).toEqual(testData.unsignedTxId); - }); - }); -}); diff --git a/packages/networks/firo-rpc/tests/mocked/rateLimitedAxios.mock.ts b/packages/networks/firo-rpc/tests/mocked/rateLimitedAxios.mock.ts deleted file mode 100644 index 84d448d5c..000000000 --- a/packages/networks/firo-rpc/tests/mocked/rateLimitedAxios.mock.ts +++ /dev/null @@ -1,17 +0,0 @@ -import RateLimitedAxios from '@rosen-clients/rate-limited-axios'; - -export const axiosInstance = { - get: vi.fn(), - post: vi.fn(), -}; - -/** - * resets axios functions mocks and call counts - */ -export const resetAxiosMock = () => { - axiosInstance.get.mockReset(); - axiosInstance.post.mockReset(); - - // Mock axios.create to return our mocked instance - vi.spyOn(RateLimitedAxios, 'create').mockReturnValue(axiosInstance as any); // eslint-disable-line @typescript-eslint/no-explicit-any -}; diff --git a/packages/networks/firo-rpc/tests/testData.ts b/packages/networks/firo-rpc/tests/testData.ts deleted file mode 100644 index 06a52de4a..000000000 --- a/packages/networks/firo-rpc/tests/testData.ts +++ /dev/null @@ -1,329 +0,0 @@ -export const lockAddress = 'DHTom1rFwsgAn5raKU1nok8E5MdQ4GBkAN'; -export const lockAddressPublicKey = - '76a914872b67c8270a9eaf5c2abf632af3dea989d2e37188ac'; - -// RPC response for blockchain info -export const blockHeightResponse = { - result: { - chain: 'main', - blocks: 5693743, - headers: 5693743, - bestblockhash: - '491256bdad2121de4a07e640795398f92da33e6082bab6c2c859f2be2a48ad1a', - difficulty: 53216508.58110767, - mediantime: 1746280884, - verificationprogress: 0.9999990831244884, - initialblockdownload: false, - chainwork: - '000000000000000000000000000000000000000000001b0199aba077b02aadc8', - size_on_disk: 189137123969, - pruned: false, - softforks: [ - { - id: 'bip34', - version: 2, - reject: { - status: true, - }, - }, - { - id: 'bip66', - version: 3, - reject: { - status: true, - }, - }, - { - id: 'bip65', - version: 4, - reject: { - status: true, - }, - }, - ], - bip9_softforks: { - csv: { - status: 'failed', - startTime: 1462060800, - timeout: 1493596800, - since: 1703520, - }, - }, - warnings: '', - }, - error: null, - id: 'getinfo_request', -}; - -// Block hash for testing -export const blockHash = - '491256bdad2121de4a07e640795398f92da33e6082bab6c2c859f2be2a48ad1a'; - -// RPC response for a block -export const blockResponse = { - result: { - hash: '491256bdad2121de4a07e640795398f92da33e6082bab6c2c859f2be2a48ad1a', - confirmations: 2, - strippedsize: 18188, - size: 18188, - weight: 72752, - height: 5693743, - version: 6422788, - versionHex: '00620104', - merkleroot: - '99b7688dd7d3cc9e0871031677d7e32c067d333927ec728986c0d0b7ef0c94ca', - tx: [ - '7b110de3db716e12de71ca59216a84c985c2f5e5c50ae783d2677d9c0df60658', - '6891a81de933788e1ca8f4735054a86ec4ddf5d768a9dc9057c8d707ea1a9a30', - '35b8fd16ecc0553c81c980f5e8355d19dbfb1e3306aa3c7f0b8861178a46125a', - '788dace4245c70eba4471addd16b545da1f8b36469e3e69580af330745add5e8', - '27856d2df08f9e34e0b7676e6e51bbf43a448f5bdb816c7c1ebe51ca1bc06834', - '6889495f54698c6306339fe3609f9e1d1cb78319f3345f91394f5f3ef6ad7804', - 'c63556a9dce4b2520d72fee915bcd3fc41783538768152933866bd867060fa0c', - 'caa9dd2e997e58444e04d4a362a86411905d6260c803e9348fa203596f330ef8', - '08ba4e8df2244f339944f66a636fb3ec312ee7ae69041d0928f1ebcb95b47887', - 'c42166250fe4f161b2cd5168fe4c18592c5d8322553ae7a815e999e864753427', - ], - time: 1746281170, - mediantime: 1746280884, - nonce: 0, - bits: '1950b4c9', - difficulty: 53216508.58110767, - chainwork: - '000000000000000000000000000000000000000000001b0199aba077b02aadc8', - previousblockhash: - 'cdb5f24555323bd352c17385ab8b9bf32362c19d8b141430acd2c01250892233', - nextblockhash: - '2620dc978e666924e772d88745bae437a5587f4292e3969fce76dc8aa618eeb8', - }, - error: null, - id: 'getblock_request', -}; - -// Block info object for testing -export const blockInfo = { - hash: '491256bdad2121de4a07e640795398f92da33e6082bab6c2c859f2be2a48ad1a', - parentHash: - 'cdb5f24555323bd352c17385ab8b9bf32362c19d8b141430acd2c01250892233', - height: 5693743, -}; - -// Transaction ID for testing -export const txId = - '19774cdc6bc663926590dc2fe7bfe77ba57a5343aaa16db5ffc377e95663fd4e'; - -// Block hash for the transaction -export const txBlockHash = - '491256bdad2121de4a07e640795398f92da33e6082bab6c2c859f2be2a48ad1a'; - -// RPC response for a transaction -export const txResponse = { - result: { - hex: '0100000001334a2a5e41047070a5497cf208b3c408998bc4be3487b8125e244bbfb742d915000000006b483045022100dce96e89af41443891626f059ab6934a5b8ac76de3b6881cdc87a0c1c578cc070220054d44f9b6241fe9fbd10482ae5f285c273025324538a3d7e419d7f3dde69d0d012103dc1945a85a6147ed5da6d6f150e9de002e40c4ff48cca96b488fe74fa9af8a88ffffffff02109e6cd81b0000001976a9144883eb0a391995f422a48595edf7a19af5e0660c88ac3b70f1bd731b00001976a914a17fdccb11e75bf95df8f760fde346357f34c7ec88ac00000000', - txid: '87ce994dacf48d97dcffd30221f70acf8c2b40ba4d5ed9be8615d79daf922c73', - hash: '87ce994dacf48d97dcffd30221f70acf8c2b40ba4d5ed9be8615d79daf922c73', - size: 226, - vsize: 226, - version: 1, - locktime: 0, - vin: [ - { - txid: '15d942b7bf4b245e12b88734bec48b9908c4b308f27c49a5707004415e2a4a33', - vout: 0, - scriptSig: { - asm: '3045022100dce96e89af41443891626f059ab6934a5b8ac76de3b6881cdc87a0c1c578cc070220054d44f9b6241fe9fbd10482ae5f285c273025324538a3d7e419d7f3dde69d0d[ALL] 03dc1945a85a6147ed5da6d6f150e9de002e40c4ff48cca96b488fe74fa9af8a88', - hex: '483045022100dce96e89af41443891626f059ab6934a5b8ac76de3b6881cdc87a0c1c578cc070220054d44f9b6241fe9fbd10482ae5f285c273025324538a3d7e419d7f3dde69d0d012103dc1945a85a6147ed5da6d6f150e9de002e40c4ff48cca96b488fe74fa9af8a88', - }, - sequence: 4294967295, - }, - ], - vout: [ - { - value: 1195.95114, - n: 0, - scriptPubKey: { - asm: 'OP_DUP OP_HASH160 4883eb0a391995f422a48595edf7a19af5e0660c OP_EQUALVERIFY OP_CHECKSIG', - hex: '76a9144883eb0a391995f422a48595edf7a19af5e0660c88ac', - reqSigs: 1, - type: 'pubkeyhash', - addresses: ['DBkXDuHLVa7a6jwaYm1qnsHqQbyaPcHFXk'], - }, - }, - { - value: 301839.21905723, - n: 1, - scriptPubKey: { - asm: 'OP_DUP OP_HASH160 a17fdccb11e75bf95df8f760fde346357f34c7ec OP_EQUALVERIFY OP_CHECKSIG', - hex: '76a914a17fdccb11e75bf95df8f760fde346357f34c7ec88ac', - reqSigs: 1, - type: 'pubkeyhash', - addresses: ['DKs2WBbiaAEe9RGzQTmtJj1o9bqsKTUTtC'], - }, - }, - ], - blockhash: - 'e9fd23982c29992396081fd391389f7648a14bd0ecd6efee22156c856743fbee', - confirmations: 4351, - time: 1746084906, - blocktime: 1746084906, - }, - error: null, - id: '19774cdc6bc663926590dc2fe7bfe77ba57a5343aaa16db5ffc377e95663fd4e', -}; -// Response for transaction not in mempool -export const txNotInMempoolResponse = { - result: null, - error: { - code: -5, - message: 'Transaction not in mempool', - }, - id: 'mempool_request', -}; - -// UTXO response for testing -export const txOutResponse = { - result: { - bestblock: - '7ece87585c3bda22c8357efaabfe1b4264ccb23360dee49a779a41c9865c5fb1', - confirmations: 4, - value: 10025.96814264, - scriptPubKey: { - asm: 'OP_DUP OP_HASH160 212da786d4574d8401d73ac295f34a9ebd98386b OP_EQUALVERIFY OP_CHECKSIG', - hex: '76a914212da786d4574d8401d73ac295f34a9ebd98386b88ac', - reqSigs: 1, - type: 'pubkeyhash', - addresses: ['D8AXXiGEZeZnMKTKnC9AWB3YUU4jfMAmYU'], - }, - version: 1, - coinbase: true, - }, - error: null, - id: 'gettxout_request', -}; - -// Fee estimation response -export const estimateSmartFeeResponse = { - result: { - feerate: 0.01001657, - blocks: 10, - }, - error: null, - id: 'fee_request', -}; - -// Transaction hex response -export const txHexResponse = { - result: - '01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff35032fe1560fe4b883e5bda9e7a59ee4bb99e9b1bc205b323032352d30352d30335431343a30363a31302e3532393530363039345a5dffffffff01b84d6d6fe90000001976a914212da786d4574d8401d73ac295f34a9ebd98386b88ac00000000', - error: null, - id: 'gettxhex_request', -}; - -// Firo transaction object -export const firoTx = { - id: '87ce994dacf48d97dcffd30221f70acf8c2b40ba4d5ed9be8615d79daf922c73', - inputs: [ - { - txId: '15d942b7bf4b245e12b88734bec48b9908c4b308f27c49a5707004415e2a4a33', - index: 0, - scriptPubKey: - '483045022100dce96e89af41443891626f059ab6934a5b8ac76de3b6881cdc87a0c1c578cc070220054d44f9b6241fe9fbd10482ae5f285c273025324538a3d7e419d7f3dde69d0d012103dc1945a85a6147ed5da6d6f150e9de002e40c4ff48cca96b488fe74fa9af8a88', - }, - ], - outputs: [ - { - value: 119595114000n, // Value in satoshis (1195.95114000 FIRO) - scriptPubKey: '76a9144883eb0a391995f422a48595edf7a19af5e0660c88ac', - }, - { - value: 30183921905723n, // Value in satoshis (301839.21905723 FIRO) - scriptPubKey: '76a914a17fdccb11e75bf95df8f760fde346357f34c7ec88ac', - }, - ], -}; - -// UTXO object -export const firoUtxo = { - txId: '19774cdc6bc663926590dc2fe7bfe77ba57a5343aaa16db5ffc377e95663fd4e', - index: 0, - value: 119595114000n, // Value in satoshis (1195.95114 FIRO) -}; - -// Include these for compatibility with existing tests -export const firoPaymentBytes = - '70736274ff0100b30200000001349ef262b9716ba26f5ddf04f9917e3149e16304a8b8b99de6b1e338dee297850200000000ffffffff030000000000000000356a33000000000005f5e10000000000009896802103e5bedab3f782ef17a73e9bdc41ee0e18c3ab477400f35bcf7caa54171db7ff3600ca9a3b0000000017a914d4c141068ab3a242aed5081a27ac3f10ad99ac9887c8e7ee5f030000001976a914872b67c8270a9eaf5c2abf632af3dea989d2e37188ac00000000000100fd1d010200000001349ef262b9716ba26f5ddf04f9917e3149e16304a8b8b99de6b1e338dee29785020000006a47304402207e4cd2745243257f0749b4a41425c2075dfb199f47072bfbf7db14b02677a8ae02204682c5159737314f7c4ba0f7112876497171a7cee48dddf667dccd59cf8ae1280121022b9ed0a9139042921decc62603a4a07357b444da2e0bd6a96c27155117913037ffffffff030000000000000000356a33000000000005f5e10000000000009896802103e5bedab3f782ef17a73e9bdc41ee0e18c3ab477400f35bcf7caa54171db7ff3600ca9a3b0000000017a914d4c141068ab3a242aed5081a27ac3f10ad99ac9887c8e7ee5f030000001976a914872b67c8270a9eaf5c2abf632af3dea989d2e37188ac0000000000000000'; - -export const unsignedTxId = 'unsigned_tx_id_placeholder'; - -// Mock UTXOs for getAddressBoxes test -export const mockAddressUtxos = [ - { - txid: txId, - outputIndex: 0, - address: lockAddress, - script: lockAddressPublicKey, - satoshis: 1050000000, - height: 5693743, - }, - { - txid: '2nd-tx-id', - outputIndex: 1, - address: lockAddress, - script: lockAddressPublicKey, - satoshis: 525000000, - height: 5693740, - }, - { - txid: '3rd-tx-id', - outputIndex: 0, - address: lockAddress, - script: lockAddressPublicKey, - satoshis: 200000000, - height: 5693738, - }, -]; - -// Expected output for getAddressBoxes test (first 2 UTXOs) -export const expectedAddressBoxes = [ - { - txId: txId, - index: 0, - value: 1050000000n, // 10.5 FIRO in satoshis - }, - { - txId: '2nd-tx-id', - index: 1, - value: 525000000n, // 5.25 FIRO in satoshis - }, -]; - -// getspentinfo response for a spent UTXO -export const spentInfoResponse = { - result: { - txid: '87ce994dacf48d97dcffd30221f70acf8c2b40ba4d5ed9be8615d79daf922c73', - index: 0, - height: 5693740, - }, - error: null, - id: 'getspentinfo_request', -}; - -// getspentinfo response for an unspent UTXO (RPC returns error) -export const unspentInfoError = { - response: { - data: { - result: null, - error: { - code: -5, - message: 'Unable to get spent info', - }, - id: 'getspentinfo_request', - }, - }, -}; - -// Expected confirmation count (should match txResponse.result.confirmations) -export const expectedTxConfirmation = 4351; - -// Total balance for address assets (sum of all mock UTXOs) -export const expectedAddressBalance = 1775000000n; // 17.75 FIRO in satoshis diff --git a/services/guard-service/config/default.yaml b/services/guard-service/config/default.yaml index 89f17d0ee..0ce9af158 100644 --- a/services/guard-service/config/default.yaml +++ b/services/guard-service/config/default.yaml @@ -68,14 +68,10 @@ bitcoinRunes: derivationPath: - firo: - chainNetwork: 'rpc' # 'rpc' - rpc: - url: '' - timeout: 8 - # username: '' - # password: '' - # apiKey: '' - # rps: 5 # request per second for RPC requests + electrumx: + host: '127.0.0.1' + port: 50001 + timeout: 30 bankPublicKey: '' # corresponding public key of lock address txFeeSlippage: 20 # allowed slippage percentage for tx fees confirmation: # required block confirmation @@ -341,7 +337,7 @@ balanceHandler: esplora: 9999 firo: tokensPerIteration: - rpc: 9999 + electrumx: 9999 doge: tokensPerIteration: esplora: 9999 diff --git a/services/guard-service/config/test.yaml b/services/guard-service/config/test.yaml index 920bb7e48..cf2fcdebf 100644 --- a/services/guard-service/config/test.yaml +++ b/services/guard-service/config/test.yaml @@ -36,10 +36,10 @@ bitcoin: - 0 - 0 firo: - chainNetwork: 'rpc' - rpc: - url: '' - timeout: 8 + electrumx: + host: '127.0.0.1' + port: 50001 + timeout: 30 bankPublicKey: 'bcb07faa6c0f19e2f2587aa9ef6f43a68fc0135321216a71dc87c8527af4ca6a' txFeeSlippage: 20 confirmation: diff --git a/services/guard-service/docker/custom-environment-variables.yaml b/services/guard-service/docker/custom-environment-variables.yaml index 489ffe303..eb22f14a8 100644 --- a/services/guard-service/docker/custom-environment-variables.yaml +++ b/services/guard-service/docker/custom-environment-variables.yaml @@ -28,10 +28,10 @@ binance: rpc: authToken: 'BINANCE_RPC_AUTH_TOKEN' firo: - rpc: - username: 'FIRO_RPC_USERNAME' - password: 'FIRO_RPC_PASSWORD' - apiKey: 'FIRO_RPC_API_KEY' + electrumx: + host: 'FIRO_ELECTRUMX_HOST' + port: 'FIRO_ELECTRUMX_PORT' + timeout: 'FIRO_ELECTRUMX_TIMEOUT' doge: rpc: username: 'DOGE_RPC_USERNAME' diff --git a/services/guard-service/package.json b/services/guard-service/package.json index 2299d0ed8..38ca07e0f 100644 --- a/services/guard-service/package.json +++ b/services/guard-service/package.json @@ -82,7 +82,7 @@ "@rosen-chains/evm": "^10.0.0", "@rosen-chains/evm-rpc": "^4.0.4", "@rosen-chains/firo": "^0.0.0", - "@rosen-chains/firo-rpc": "^0.0.0", + "@rosen-chains/firo-electrumx": "^0.0.0", "@rosen-clients/rate-limited-axios": "^2.0.0", "await-semaphore": "^0.1.3", "blakejs": "^1.2.1", diff --git a/services/guard-service/src/configs/guardsFiroConfigs.ts b/services/guard-service/src/configs/guardsFiroConfigs.ts index cc375477d..f21403a4b 100644 --- a/services/guard-service/src/configs/guardsFiroConfigs.ts +++ b/services/guard-service/src/configs/guardsFiroConfigs.ts @@ -2,27 +2,21 @@ import config from 'config'; import { FIRO_CHAIN, FiroConfigs } from '@rosen-chains/firo'; -import { getChainNetworkName, getConfigIntKeyOrDefault } from './configs'; +import { getConfigIntKeyOrDefault } from './configs'; import { rosenConfig } from './rosenConfig'; class GuardsFiroConfigs { // service configs - static chainNetworkName = getChainNetworkName('firo.chainNetwork', ['rpc']); - static rpc = { - url: config.get('firo.rpc.url'), - timeout: config.get('firo.rpc.timeout'), // seconds - username: config.has('firo.rpc.username') - ? config.get('firo.rpc.username') - : undefined, - password: config.has('firo.rpc.password') - ? config.get('firo.rpc.password') - : undefined, - apiKey: config.has('firo.rpc.apiKey') - ? config.get('firo.rpc.apiKey') - : undefined, - rps: config.has('firo.rpc.rps') - ? config.get('firo.rpc.rps') - : undefined, + static electrumx = { + host: config.has('firo.electrumx.host') + ? config.get('firo.electrumx.host') + : '127.0.0.1', + port: config.has('firo.electrumx.port') + ? config.get('firo.electrumx.port') + : 50001, + timeout: config.has('firo.electrumx.timeout') + ? config.get('firo.electrumx.timeout') + : 30, }; // value configs diff --git a/services/guard-service/src/handlers/chainHandler.ts b/services/guard-service/src/handlers/chainHandler.ts index 16881dc58..3f373134d 100644 --- a/services/guard-service/src/handlers/chainHandler.ts +++ b/services/guard-service/src/handlers/chainHandler.ts @@ -46,7 +46,7 @@ import { ETHEREUM_CHAIN, EthereumChain } from '@rosen-chains/ethereum'; import { AbstractEvmNetwork } from '@rosen-chains/evm'; import EvmRpcNetwork from '@rosen-chains/evm-rpc'; import { FIRO_CHAIN, FiroChain } from '@rosen-chains/firo'; -import { FiroRpcNetwork } from '@rosen-chains/firo-rpc'; +import { FiroElectrumXNetwork } from '@rosen-chains/firo-electrumx'; import { RateLimitedAxiosConfig } from '@rosen-clients/rate-limited-axios'; import GuardsBinanceConfigs from '../configs/guardsBinanceConfigs'; @@ -292,27 +292,17 @@ class ChainHandler { * @returns FiroChain object */ private generateFiroChain = (): FiroChain => { - const network = new FiroRpcNetwork( - GuardsFiroConfigs.rpc.url, + const network = new FiroElectrumXNetwork( + GuardsFiroConfigs.electrumx.host, + GuardsFiroConfigs.electrumx.port, async (txId: string) => { const tx = await DatabaseAction.getInstance().getTxById(txId); if (tx === null) return undefined; return TransactionSerializer.fromJson(tx.txJson, this.getChain); }, - DefaultLogger.getInstance().child('FiroRpcNetwork'), - { - username: GuardsFiroConfigs.rpc.username, - password: GuardsFiroConfigs.rpc.password, - apiKey: GuardsFiroConfigs.rpc.apiKey, - }, + DefaultLogger.getInstance().child('FiroElectrumXNetwork'), + GuardsFiroConfigs.electrumx.timeout * 1000, ); - if (GuardsFiroConfigs.rpc.rps !== undefined) - RateLimitedAxiosConfig.addRule( - GuardsFiroConfigs.rpc.url, - GuardsFiroConfigs.rpc.rps, - 1, - GuardsFiroConfigs.rpc.timeout, - ); const chainCode = GuardsFiroConfigs.tssChainCode; const derivationPath = GuardsFiroConfigs.derivationPath; const firoSignMediator = TssHandler.getInstance().wrapCurveSignMediator( diff --git a/services/guard-service/tsconfig.json b/services/guard-service/tsconfig.json index db923f8ee..27a125b86 100644 --- a/services/guard-service/tsconfig.json +++ b/services/guard-service/tsconfig.json @@ -25,7 +25,7 @@ { "path": "../../packages/networks/bitcoin-runes-rpc" }, { "path": "../../packages/networks/cardano-blockfrost" }, { "path": "../../packages/networks/cardano-koios" }, - { "path": "../../packages/networks/firo-rpc" }, + { "path": "../../packages/networks/firo-electrumx" }, { "path": "../../packages/networks/doge-blockcypher" }, { "path": "../../packages/networks/doge-esplora" }, { "path": "../../packages/networks/doge-rpc" }, From f0eb0f6335c8ffd9140e092f81632b13a1704981 Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Sat, 30 May 2026 14:30:18 +0100 Subject: [PATCH 2/4] fix(firo): ElectrumX address scripthash conversion --- package-lock.json | 27 ++------- .../lib/firoElectrumxNetwork.ts | 60 +++++++++++++++---- .../tests/firoElectrumxNetwork.spec.ts | 30 +++++++++- 3 files changed, 83 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 979ed715a..a7faa8a29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5163,8 +5163,8 @@ "resolved": "packages/chains/firo", "link": true }, - "node_modules/@rosen-chains/firo-rpc": { - "resolved": "packages/networks/firo-rpc", + "node_modules/@rosen-chains/firo-electrumx": { + "resolved": "packages/networks/firo-electrumx", "link": true }, "node_modules/@rosen-chains/handshake": { @@ -18504,16 +18504,14 @@ "npm": "11.6.2" } }, - "packages/networks/firo-rpc": { - "name": "@rosen-chains/firo-rpc", + "packages/networks/firo-electrumx": { + "name": "@rosen-chains/firo-electrumx", "version": "0.0.0", "license": "MIT", "dependencies": { "@rosen-bridge/abstract-logger": "^4.0.0", - "@rosen-bridge/json-bigint": "^1.1.0", "@rosen-chains/abstract-chain": "^16.0.0", "@rosen-chains/firo": "^0.0.0", - "@rosen-clients/rate-limited-axios": "^2.0.0", "bitcoinjs-lib": "^6.1.5" }, "engines": { @@ -18521,21 +18519,6 @@ "npm": "11.6.2" } }, - "packages/networks/firo-rpc/node_modules/@rosen-clients/rate-limited-axios": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@rosen-clients/rate-limited-axios/-/rate-limited-axios-2.0.0.tgz", - "integrity": "sha512-2fYR5O+c57uvI8V0jXi2Ny6eZ8ykIHmtN6wlEg8SNhW1gvfJas8scIoF+AOLx9C+5Z9KlN6usV8fnaTa7OicaQ==", - "license": "MIT", - "dependencies": { - "@rosen-bridge/abstract-logger": "^4.0.0", - "await-semaphore": "^0.1.3", - "axios": "^1.13.2" - }, - "engines": { - "node": ">=22.18.0", - "npm": "11.6.2" - } - }, "packages/networks/handshake-rpc": { "name": "@rosen-chains/handshake-rpc", "version": "0.0.0", @@ -18626,7 +18609,7 @@ "@rosen-chains/evm": "^10.0.0", "@rosen-chains/evm-rpc": "^4.0.4", "@rosen-chains/firo": "^0.0.0", - "@rosen-chains/firo-rpc": "^0.0.0", + "@rosen-chains/firo-electrumx": "^0.0.0", "@rosen-clients/rate-limited-axios": "^2.0.0", "await-semaphore": "^0.1.3", "blakejs": "^1.2.1", diff --git a/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts b/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts index 90b27f55d..40802d366 100644 --- a/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts +++ b/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts @@ -20,6 +20,15 @@ import { const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +// Current Firo prefixes plus legacy D-address P2PKH used by Rosen configs. +const FIRO_P2PKH_PREFIXES = new Set([ + 0x1e, + 0x41, + 0x42, + FIRO_NETWORK.pubKeyHash, +]); +const FIRO_P2SH_PREFIXES = new Set([FIRO_NETWORK.scriptHash, 0xb2, 0xb3]); + function base58Decode(encoded: string): Buffer { const bytes: number[] = []; for (let i = 0; i < encoded.length; i++) { @@ -47,17 +56,46 @@ function base58Decode(encoded: string): Buffer { return Buffer.from(bytes.reverse()); } -function addressToScripthash(address: string): string { +export function addressToScripthash(address: string): string { const decoded = base58Decode(address); - const pubkeyHash = decoded.subarray(1, 21); - // P2PKH script: OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG - const script = Buffer.concat([ - Buffer.from([0x76, 0xa9, 0x14]), - pubkeyHash, - Buffer.from([0x88, 0xac]), - ]); - const hash = crypto.createHash('sha256').update(script).digest(); - return hash.reverse().toString('hex'); + if (decoded.length !== 25) { + throw new Error(`Invalid Firo address length: ${decoded.length}`); + } + + const payload = decoded.subarray(0, 21); + const checksum = decoded.subarray(21); + const expectedChecksum = doubleSha256(payload).subarray(0, 4); + if (!checksum.equals(expectedChecksum)) { + throw new Error('Invalid Firo address checksum'); + } + + const version = payload[0]; + if (version === undefined) { + throw new Error('Invalid Firo address version'); + } + + const payloadHash = payload.subarray(1); + let script: Buffer; + if (FIRO_P2PKH_PREFIXES.has(version)) { + // P2PKH script: OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG + script = Buffer.concat([ + Buffer.from([0x76, 0xa9, 0x14]), + payloadHash, + Buffer.from([0x88, 0xac]), + ]); + } else if (FIRO_P2SH_PREFIXES.has(version)) { + // P2SH script: OP_HASH160 <20 bytes> OP_EQUAL + script = Buffer.concat([ + Buffer.from([0xa9, 0x14]), + payloadHash, + Buffer.from([0x87]), + ]); + } else { + throw new Error(`Unsupported Firo address version: ${version}`); + } + + const scriptHash = crypto.createHash('sha256').update(script).digest(); + return scriptHash.reverse().toString('hex'); } function reverseHex(hex: string): string { @@ -67,7 +105,7 @@ function reverseHex(hex: string): string { function doubleSha256(data: Buffer): Buffer { return crypto.createHash('sha256').update( - crypto.createHash('sha256').update(data).digest() + crypto.createHash('sha256').update(data).digest(), ).digest(); } diff --git a/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts b/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts index 03d448171..21c9abb52 100644 --- a/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts +++ b/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts @@ -8,7 +8,9 @@ import { } from '@rosen-chains/abstract-chain'; import { setMockResponses, resetMock } from './mocked/electrumxSocket.mock'; -import FiroElectrumXNetwork from '../lib/firoElectrumxNetwork'; +import FiroElectrumXNetwork, { + addressToScripthash, +} from '../lib/firoElectrumxNetwork'; import * as testData from './testData'; describe('FiroElectrumXNetwork', () => { @@ -22,6 +24,32 @@ describe('FiroElectrumXNetwork', () => { mockGetSavedTransactionById.mockReturnValue(undefined); }); + describe('addressToScripthash', () => { + it('should produce correct scripthash for P2PKH address', () => { + const scripthash = addressToScripthash(testData.lockAddress); + + expect(scripthash).toBe( + '53787b5ebd3152e257d1ed402ca773aa83fca5981ed9c3b02bf9e5299dd36960', + ); + }); + + it('should produce correct scripthash for P2SH address', () => { + const scripthash = addressToScripthash( + '2EdAinnuw3zCy8arpSKRwQYQK2MBC5VMXu9', + ); + + expect(scripthash).toBe( + '7914236249d96d4931978817b2fe3c9071e8b4daf4decd3087dbba955fd7f66f', + ); + }); + + it('should throw for invalid checksum', () => { + expect(() => + addressToScripthash('THzVvKwY5dAD6gM5z4Mz3jG9RbqhkS8h7W'), + ).toThrow('checksum'); + }); + }); + describe('getHeight', () => { /** * @target `FiroElectrumXNetwork.getHeight` should return block height successfully From e2eec15946af0539fc9346abf7f22456ae45bfe9 Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Sun, 31 May 2026 10:55:38 +0100 Subject: [PATCH 3/4] fix(firo): formatting fix --- .../lib/firoElectrumxNetwork.ts | 158 +++++++++++------- .../tests/firoElectrumxNetwork.spec.ts | 148 +++++++++------- .../tests/mocked/electrumxSocket.mock.ts | 40 ++--- 3 files changed, 206 insertions(+), 140 deletions(-) diff --git a/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts b/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts index 40802d366..55301798d 100644 --- a/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts +++ b/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts @@ -1,7 +1,7 @@ +import { Psbt } from 'bitcoinjs-lib'; import * as crypto from 'crypto'; import * as net from 'net'; -import { Psbt } from 'bitcoinjs-lib'; import { AbstractLogger } from '@rosen-bridge/abstract-logger'; import { BlockInfo, @@ -19,6 +19,8 @@ import { const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +const BASE58_REGEX = + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; // Current Firo prefixes plus legacy D-address P2PKH used by Rosen configs. const FIRO_P2PKH_PREFIXES = new Set([ @@ -104,9 +106,10 @@ function reverseHex(hex: string): string { } function doubleSha256(data: Buffer): Buffer { - return crypto.createHash('sha256').update( - crypto.createHash('sha256').update(data).digest(), - ).digest(); + return crypto + .createHash('sha256') + .update(crypto.createHash('sha256').update(data).digest()) + .digest(); } function readVarInt(data: Buffer, offset: number): [number, number] { @@ -140,7 +143,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { private connectPromise: Promise | null = null; private serverVersionSent = false; - // Block hash → height cache for resolving getBlockInfo/getBlockTransactionIds + // Block hash to height cache for resolving block methods. private hashToHeight = new Map(); private lastKnownHeight = 0; @@ -246,7 +249,9 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { if (response.error) { pending.reject( new Error( - `ElectrumX error: ${response.error.message || JSON.stringify(response.error)}`, + `ElectrumX error: ${ + response.error.message || JSON.stringify(response.error) + }`, ), ); } else { @@ -262,7 +267,9 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { socket.on('error', (err: Error) => { this.socket = null; this.serverVersionSent = false; - this.rejectAllPending(new NetworkError(`TCP socket error: ${err.message}`)); + this.rejectAllPending( + new NetworkError(`TCP socket error: ${err.message}`), + ); }); socket.on('close', () => { @@ -302,7 +309,10 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { } }; - private sendRequest = (method: string, params: unknown[]): Promise => { + private sendRequest = ( + method: string, + params: unknown[], + ): Promise => { return new Promise((resolve, reject) => { const id = this.nextId++; this.pendingRequests.set(id, { resolve, reject }); @@ -339,26 +349,34 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { this.pendingRequests.delete(id); reject( new NetworkError( - `Failed to send ElectrumX request: ${err instanceof Error ? err.message : 'Unknown error'}`, + `Failed to send ElectrumX request: ${ + err instanceof Error ? err.message : 'Unknown error' + }`, ), ); } }); }; - // ─── AbstractChainNetwork methods ──────────────────────────────────────── + // AbstractChainNetwork methods. getHeight = async (): Promise => { try { await this.ensureConnected(); - const result = (await this.sendRequest('blockchain.headers.subscribe', [])) as { + const result = (await this.sendRequest( + 'blockchain.headers.subscribe', + [], + )) as { height: number; }; this.lastKnownHeight = result.height; this.logger.debug(`Current height: ${result.height}`); return result.height; } catch (e) { - throw this.wrapError('Failed to fetch current height from Firo ElectrumX', e); + throw this.wrapError( + 'Failed to fetch current height from Firo ElectrumX', + e, + ); } }; @@ -370,7 +388,8 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { height, ])) as Array; this.logger.debug( - `Block [${blockId}] at height [${height}] has ${result.length} transactions`, + `Block [${blockId}] at height [${height}] has ` + + `${result.length} transactions`, ); return result; } catch (e) { @@ -403,7 +422,8 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { this.hashToHeight.set(hash, height); this.logger.debug( - `Block [${blockId}] at height [${height}]: hash=${hash}, parent=${parentHash}`, + `Block [${blockId}] at height [${height}]: ` + + `hash=${hash}, parent=${parentHash}`, ); return { hash, parentHash, height }; @@ -440,15 +460,13 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { const txHex = transaction.extractTransaction(true).toHex(); try { await this.ensureConnected(); - const result = (await this.sendRequest('blockchain.transaction.broadcast', [ - txHex, - ])) as string; + const result = (await this.sendRequest( + 'blockchain.transaction.broadcast', + [txHex], + )) as string; this.logger.debug(`Submitted transaction. Result: ${result}`); } catch (e) { - throw this.wrapError( - 'Failed to submit transaction to Firo ElectrumX', - e, - ); + throw this.wrapError('Failed to submit transaction to Firo ElectrumX', e); } }; @@ -458,7 +476,8 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { getTokenDetail = async (tokenId: string) => { throw new Error( - `Firo network does not support token [${tokenId}]. Only native token is supported.`, + `Firo network does not support token [${tokenId}]. ` + + 'Only native token is supported.', ); }; @@ -475,16 +494,18 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { }> => { try { // Validate address contains only base58 characters - if (!/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/.test(address)) { + if (!BASE58_REGEX.test(address)) { return { nativeToken: 0n, tokens: [] }; } const scripthash = addressToScripthash(address); await this.ensureConnected(); - const result = (await this.sendRequest('blockchain.scripthash.get_balance', [ - scripthash, - ])) as { confirmed: number; unconfirmed: number }; + const result = (await this.sendRequest( + 'blockchain.scripthash.get_balance', + [scripthash], + )) as { confirmed: number; unconfirmed: number }; this.logger.debug( - `Address [${address}] balance: confirmed=${result.confirmed}, unconfirmed=${result.unconfirmed}`, + `Address [${address}] balance: confirmed=${result.confirmed}, ` + + `unconfirmed=${result.unconfirmed}`, ); return { nativeToken: BigInt(result.confirmed), @@ -498,7 +519,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { } }; - // ─── AbstractUtxoChainNetwork methods ──────────────────────────────────── + // AbstractUtxoChainNetwork methods. getAddressBoxes = async ( address: string, @@ -507,14 +528,15 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { ): Promise> => { try { // Validate address contains only base58 characters - if (!/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/.test(address)) { + if (!BASE58_REGEX.test(address)) { return []; } const scripthash = addressToScripthash(address); await this.ensureConnected(); - const utxos = (await this.sendRequest('blockchain.scripthash.listunspent', [ - scripthash, - ])) as Array<{ + const utxos = (await this.sendRequest( + 'blockchain.scripthash.listunspent', + [scripthash], + )) as Array<{ tx_hash: string; tx_pos: number; height: number; @@ -532,7 +554,8 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { })); this.logger.debug( - `Address [${address}] has ${utxos.length} UTXOs, returning ${firoUtxos.length} after pagination`, + `Address [${address}] has ${utxos.length} UTXOs, ` + + `returning ${firoUtxos.length} after pagination`, ); return firoUtxos; } catch (e) { @@ -563,12 +586,17 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { // and checking against listunspent const scriptPubKey = tx.outputs[outputIndex]!.scriptPubKey; const scripthash = reverseHex( - crypto.createHash('sha256').update(Buffer.from(scriptPubKey, 'hex')).digest().toString('hex'), + crypto + .createHash('sha256') + .update(Buffer.from(scriptPubKey, 'hex')) + .digest() + .toString('hex'), ); - const unspent = (await this.sendRequest('blockchain.scripthash.listunspent', [ - scripthash, - ])) as Array<{ tx_hash: string; tx_pos: number }>; + const unspent = (await this.sendRequest( + 'blockchain.scripthash.listunspent', + [scripthash], + )) as Array<{ tx_hash: string; tx_pos: number }>; return unspent.some( (utxo) => utxo.tx_hash === txId && utxo.tx_pos === outputIndex, @@ -585,7 +613,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { } }; - // ─── AbstractFiroNetwork methods ───────────────────────────────────────── + // AbstractFiroNetwork methods. getUtxo = async (boxId: string): Promise => { const [txId, outputIndexStr] = boxId.split('.'); @@ -625,9 +653,10 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { // ElectrumX returns fee in BTC/kB, convert to satoshis/byte if (feeRate <= 0) { // ElectrumX returns -1 when it cannot estimate (not enough data). - // Use a conservative fallback: 10 sat/byte covers typical low-fee periods. + // Use a conservative fallback for typical low-fee periods. this.logger.warn( - `ElectrumX estimatefee returned ${feeRate}, using fallback 10 sat/byte`, + `ElectrumX estimatefee returned ${feeRate}, ` + + `using fallback 10 sat/byte`, ); return 10; } @@ -636,10 +665,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { this.logger.debug(`Fee ratio: ${feePerByte} sat/byte`); return feePerByte; } catch (e) { - throw this.wrapError( - 'Failed to get fee ratio from Firo ElectrumX', - e, - ); + throw this.wrapError('Failed to get fee ratio from Firo ElectrumX', e); } }; @@ -672,7 +698,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { } }; - // ─── Transaction ID resolution ─────────────────────────────────────────── + // Transaction ID resolution. protected getTxConfirmationSigned = async ( transactionId: string, @@ -698,7 +724,8 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { return -1; } this.logger.debug( - `tx [${transactionId}] verbose lookup failed, assuming unconfirmed: ${e}`, + `tx [${transactionId}] verbose lookup failed, ` + + `assuming unconfirmed: ${e}`, ); return -1; } @@ -756,7 +783,8 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { } else { // Method 2: Fallback (no RPC available for ElectrumX) this.logger.debug( - `Direct PSBT extraction failed for hash [${hash}]. RPC lookup not available with ElectrumX.`, + `Direct PSBT extraction failed for hash [${hash}]. ` + + 'RPC lookup not available with ElectrumX.', ); } } @@ -770,7 +798,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { return actualTxId; }; - // ─── Helpers ───────────────────────────────────────────────────────────── + // Helpers. private resolveHeight = async (blockHash: string): Promise => { // Check cache first @@ -779,11 +807,14 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { // Try to find it by searching from current height backwards await this.ensureConnected(); - const searchStart = this.lastKnownHeight > 0 ? this.lastKnownHeight : ( - (await this.sendRequest('blockchain.headers.subscribe', [])) as { - height: number; - } - ).height; + const searchStart = + this.lastKnownHeight > 0 + ? this.lastKnownHeight + : ( + (await this.sendRequest('blockchain.headers.subscribe', [])) as { + height: number; + } + ).height; this.lastKnownHeight = searchStart; // Search up to 1000 blocks back (~42 hours at 2 blocks per 5 minutes) @@ -799,7 +830,8 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { } throw new Error( - `Block [${blockHash}] not found within 1000 blocks of height ${searchStart}`, + `Block [${blockHash}] not found within 1000 blocks ` + + `of height ${searchStart}`, ); }; @@ -830,13 +862,17 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { const [scriptSigLen, scriptOffset] = readVarInt(buf, offset); offset = scriptOffset; // ScriptSig - const scriptSig = buf.subarray(offset, offset + scriptSigLen).toString('hex'); + const scriptSig = buf + .subarray(offset, offset + scriptSigLen) + .toString('hex'); offset += scriptSigLen; // Sequence (4 bytes) offset += 4; const txIdStr = prevTxHash.reverse().toString('hex'); - const isCoinbase = txIdStr === '0000000000000000000000000000000000000000000000000000000000000000'; + const isCoinbase = + txIdStr === + '0000000000000000000000000000000000000000000000000000000000000000'; inputs.push({ txId: isCoinbase ? '' : txIdStr, index: isCoinbase ? -1 : prevIndex, @@ -856,7 +892,9 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { const [scriptLen, scriptOff] = readVarInt(buf, offset); offset = scriptOff; // ScriptPubKey - const scriptPubKey = buf.subarray(offset, offset + scriptLen).toString('hex'); + const scriptPubKey = buf + .subarray(offset, offset + scriptLen) + .toString('hex'); offset += scriptLen; outputs.push({ scriptPubKey, value }); @@ -866,7 +904,11 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { }; private wrapError = (baseMessage: string, e: unknown): Error => { - if (e instanceof FailedError || e instanceof NetworkError || e instanceof UnexpectedApiError) { + if ( + e instanceof FailedError || + e instanceof NetworkError || + e instanceof UnexpectedApiError + ) { return e; } if (e instanceof Error) { diff --git a/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts b/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts index 21c9abb52..a9a028ddb 100644 --- a/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts +++ b/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts @@ -7,16 +7,18 @@ import { TransactionType, } from '@rosen-chains/abstract-chain'; -import { setMockResponses, resetMock } from './mocked/electrumxSocket.mock'; import FiroElectrumXNetwork, { addressToScripthash, } from '../lib/firoElectrumxNetwork'; +import { setMockResponses, resetMock } from './mocked/electrumxSocket.mock'; import * as testData from './testData'; describe('FiroElectrumXNetwork', () => { const HOST = '127.0.0.1'; const PORT = 50001; const mockGetSavedTransactionById = vi.fn().mockReturnValue(undefined); + const createNetwork = () => + new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); beforeEach(() => { resetMock(); @@ -65,7 +67,7 @@ describe('FiroElectrumXNetwork', () => { it('should return block height successfully', async () => { setMockResponses([testData.blockHeightResponse]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getHeight(); expect(result).toEqual(testData.blockHeightResponse.height); @@ -86,7 +88,7 @@ describe('FiroElectrumXNetwork', () => { throw new Error('Connection refused'); }); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); await expect(network.getHeight()).rejects.toThrow(NetworkError); }); }); @@ -105,7 +107,7 @@ describe('FiroElectrumXNetwork', () => { it('should return block tx ids successfully', async () => { setMockResponses([testData.blockTxIds]); // only blockchain.block.txids is called - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); // Pre-populate the height cache so resolveHeight returns immediately // eslint-disable-next-line @typescript-eslint/no-explicit-any (network as any).hashToHeight.set(testData.blockHash, 42); @@ -133,9 +135,12 @@ describe('FiroElectrumXNetwork', () => { testData.blockHeaderHex, // blockchain.block.header response ]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (network as any).hashToHeight.set(testData.blockHash, testData.blockInfo.height); + (network as any).hashToHeight.set( + testData.blockHash, + testData.blockInfo.height, + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any (network as any).lastKnownHeight = testData.blockInfo.height; @@ -159,7 +164,7 @@ describe('FiroElectrumXNetwork', () => { it('should return transaction successfully', async () => { setMockResponses([testData.txHex]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getTransaction( testData.txId, testData.txBlockHash, @@ -168,7 +173,9 @@ describe('FiroElectrumXNetwork', () => { expect(result.id).toEqual(testData.txId); expect(result.inputs.length).toEqual(testData.firoTx.inputs.length); expect(result.outputs.length).toEqual(testData.firoTx.outputs.length); - expect(result.outputs[0]!.value).toEqual(testData.firoTx.outputs[0]!.value); + expect(result.outputs[0]!.value).toEqual( + testData.firoTx.outputs[0]!.value, + ); expect(result.outputs[0]!.scriptPubKey).toEqual( testData.firoTx.outputs[0]!.scriptPubKey, ); @@ -177,7 +184,7 @@ describe('FiroElectrumXNetwork', () => { it('should parse a version 3 Firo transaction with packed type', async () => { setMockResponses([testData.txHexV3Typed]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getTransaction( testData.txId, testData.txBlockHash, @@ -186,7 +193,9 @@ describe('FiroElectrumXNetwork', () => { expect(result.id).toEqual(testData.txId); expect(result.inputs.length).toEqual(testData.firoTx.inputs.length); expect(result.outputs.length).toEqual(testData.firoTx.outputs.length); - expect(result.outputs[0]!.value).toEqual(testData.firoTx.outputs[0]!.value); + expect(result.outputs[0]!.value).toEqual( + testData.firoTx.outputs[0]!.value, + ); expect(result.outputs[1]!.scriptPubKey).toEqual( testData.firoTx.outputs[1]!.scriptPubKey, ); @@ -206,10 +215,17 @@ describe('FiroElectrumXNetwork', () => { it('should return true for unspent output', async () => { setMockResponses([ testData.txHex, - [{ tx_hash: testData.txId, tx_pos: 0, height: 42, value: 119595114000 }], + [ + { + tx_hash: testData.txId, + tx_pos: 0, + height: 42, + value: 119595114000, + }, + ], ]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.isBoxUnspentAndValid(`${testData.txId}.0`); expect(result).toEqual(true); @@ -230,7 +246,7 @@ describe('FiroElectrumXNetwork', () => { [], // empty listunspent means output is spent ]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.isBoxUnspentAndValid(`${testData.txId}.0`); expect(result).toEqual(false); @@ -247,13 +263,13 @@ describe('FiroElectrumXNetwork', () => { */ it("should return false when transaction doesn't exist", async () => { // Simulate error by providing no response (the mock will time out) - // Actually, the mock handles this by having sendRequest reject for missing response + // The mock handles this by rejecting missing responses. // Better approach: use a real error response from ElectrumX setMockResponses([ { error: { message: 'No such transaction', code: -5 } }, ]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.isBoxUnspentAndValid(`nonexistent.0`); expect(result).toEqual(false); @@ -273,7 +289,7 @@ describe('FiroElectrumXNetwork', () => { it('should return UTXO data successfully', async () => { setMockResponses([testData.txHex]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getUtxo(`${testData.txId}.0`); expect(result.txId).toEqual(testData.txId); @@ -293,7 +309,7 @@ describe('FiroElectrumXNetwork', () => { it('should throw FailedError for invalid output index', async () => { setMockResponses([testData.txHex]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); await expect(network.getUtxo(`${testData.txId}.999`)).rejects.toThrow( FailedError, ); @@ -313,7 +329,7 @@ describe('FiroElectrumXNetwork', () => { it('should return fee ratio successfully', async () => { setMockResponses([testData.estimatedFee]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getFeeRatio(); const expectedFeeRate = Math.ceil( @@ -334,11 +350,9 @@ describe('FiroElectrumXNetwork', () => { * - it should return true */ it('should return true when tx is in mempool', async () => { - setMockResponses([ - { hex: testData.txHex }, - ]); + setMockResponses([{ hex: testData.txHex }]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.isTxInMempool(testData.txId); expect(result).toEqual(true); @@ -362,7 +376,7 @@ describe('FiroElectrumXNetwork', () => { }, ]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.isTxInMempool(testData.txId); expect(result).toEqual(false); @@ -378,11 +392,9 @@ describe('FiroElectrumXNetwork', () => { * - it should return false */ it('should return false when tx is not found', async () => { - setMockResponses([ - { error: { message: 'not found', code: -1 } }, - ]); + setMockResponses([{ error: { message: 'not found', code: -1 } }]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.isTxInMempool(testData.txId); expect(result).toEqual(false); @@ -402,7 +414,7 @@ describe('FiroElectrumXNetwork', () => { it('should return transaction hex successfully', async () => { setMockResponses([testData.txHex]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getTransactionHex(testData.txId); expect(result).toEqual(testData.txHex); @@ -430,7 +442,7 @@ describe('FiroElectrumXNetwork', () => { }), }; - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); await expect( // eslint-disable-next-line @typescript-eslint/no-explicit-any network.submitTransaction(mockPsbt as any), @@ -451,7 +463,7 @@ describe('FiroElectrumXNetwork', () => { it('should return address UTXOs successfully with pagination', async () => { setMockResponses([testData.mockAddressUtxos]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getAddressBoxes(testData.lockAddress, 0, 2); expect(result).toEqual(testData.expectedAddressBoxes); @@ -469,7 +481,7 @@ describe('FiroElectrumXNetwork', () => { it('should handle empty address', async () => { setMockResponses([[]]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getAddressBoxes('empty-address', 0, 10); expect(result).toEqual([]); @@ -495,7 +507,7 @@ describe('FiroElectrumXNetwork', () => { }, ]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getTxConfirmation(testData.txId); expect(result).toEqual(testData.expectedTxConfirmation); @@ -511,11 +523,9 @@ describe('FiroElectrumXNetwork', () => { * - it should return -1 */ it('should return -1 for unconfirmed transaction', async () => { - setMockResponses([ - { hex: testData.txHex }, - ]); + setMockResponses([{ hex: testData.txHex }]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getTxConfirmation(testData.txId); expect(result).toEqual(-1); @@ -531,11 +541,9 @@ describe('FiroElectrumXNetwork', () => { * - it should return -1 */ it('should return -1 when transaction is not found', async () => { - setMockResponses([ - { error: { message: 'not found', code: -1 } }, - ]); + setMockResponses([{ error: { message: 'not found', code: -1 } }]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getTxConfirmation('nonexistent-tx-id'); expect(result).toEqual(-1); @@ -561,12 +569,16 @@ describe('FiroElectrumXNetwork', () => { TransactionType.payment, ); - const customNetwork = new FiroElectrumXNetwork(HOST, PORT, async (txId: string) => { - if (txId === testData.unsignedTxId) { - return firoPayment; - } - return undefined; - }); + const customNetwork = new FiroElectrumXNetwork( + HOST, + PORT, + async (txId: string) => { + if (txId === testData.unsignedTxId) { + return firoPayment; + } + return undefined; + }, + ); const getTxConfirmationSignedSpy = vi.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -588,7 +600,9 @@ describe('FiroElectrumXNetwork', () => { }, ]); - const result = await customNetwork.getTxConfirmation(testData.unsignedTxId); + const result = await customNetwork.getTxConfirmation( + testData.unsignedTxId, + ); expect(getTxConfirmationSignedSpy).toHaveBeenCalledExactlyOnceWith( testData.txId, @@ -610,7 +624,7 @@ describe('FiroElectrumXNetwork', () => { it('should return address balance successfully', async () => { setMockResponses([testData.balanceResponse]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getAddressAssets(testData.lockAddress); expect(result.nativeToken).toEqual(testData.expectedAddressBalance); @@ -629,7 +643,7 @@ describe('FiroElectrumXNetwork', () => { it('should return 0 for empty address', async () => { setMockResponses([{ confirmed: 0, unconfirmed: 0 }]); - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getAddressAssets('empty-address'); expect(result.nativeToken).toEqual(0n); @@ -648,7 +662,7 @@ describe('FiroElectrumXNetwork', () => { * - it should return undefined */ it('should return undefined (no getspentinfo in ElectrumX)', async () => { - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (network as any).getSpentTransactionByInputId( 0, @@ -670,7 +684,7 @@ describe('FiroElectrumXNetwork', () => { * - it should return the same hash */ it('should return the same hash when no saved transaction exists', async () => { - const network = new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); + const network = createNetwork(); const result = await network.getActualTxId(testData.txId); expect(result).toEqual(testData.txId); @@ -695,12 +709,16 @@ describe('FiroElectrumXNetwork', () => { TransactionType.payment, ); - const customNetwork = new FiroElectrumXNetwork(HOST, PORT, async (txId: string) => { - if (txId === testData.unsignedTxId) { - return firoPayment; - } - return undefined; - }); + const customNetwork = new FiroElectrumXNetwork( + HOST, + PORT, + async (txId: string) => { + if (txId === testData.unsignedTxId) { + return firoPayment; + } + return undefined; + }, + ); const extractDirectSpy = vi // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -731,12 +749,16 @@ describe('FiroElectrumXNetwork', () => { TransactionType.payment, ); - const customNetwork = new FiroElectrumXNetwork(HOST, PORT, async (txId: string) => { - if (txId === testData.unsignedTxId) { - return firoPayment; - } - return undefined; - }); + const customNetwork = new FiroElectrumXNetwork( + HOST, + PORT, + async (txId: string) => { + if (txId === testData.unsignedTxId) { + return firoPayment; + } + return undefined; + }, + ); vi.spyOn( // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts b/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts index b05e4a5c7..16d729da9 100644 --- a/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts +++ b/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts @@ -1,5 +1,5 @@ -import { vi } from 'vitest'; import { EventEmitter } from 'events'; +import { vi } from 'vitest'; let mockResponses: Array = []; @@ -34,15 +34,16 @@ function createMockSocket() { try { const req = JSON.parse(line); if (req.method === 'server.version') { - setTimeout(() => - socket.emit( - 'data', - JSON.stringify({ - jsonrpc: '2.0', - id: req.id, - result: ['guard-service', '1.4'], - }) + '\n', - ), + setTimeout( + () => + socket.emit( + 'data', + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + result: ['guard-service', '1.4'], + }) + '\n', + ), 0, ); } else { @@ -53,15 +54,16 @@ function createMockSocket() { 'error' in resp && resp.error !== null && resp.error !== undefined; - setTimeout(() => - socket.emit( - 'data', - JSON.stringify({ - jsonrpc: '2.0', - id: req.id, - [isError ? 'error' : 'result']: isError ? resp.error : resp, - }) + '\n', - ), + setTimeout( + () => + socket.emit( + 'data', + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + [isError ? 'error' : 'result']: isError ? resp.error : resp, + }) + '\n', + ), 0, ); } From bc2a8ec7b934c67f13e6ea27b0f5ceef18878a1b Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Sun, 31 May 2026 11:25:38 +0100 Subject: [PATCH 4/4] Use TLS for Firo ElectrumX guard network --- packages/networks/firo-electrumx/README.md | 2 +- .../lib/firoElectrumxNetwork.ts | 20 +++-- packages/networks/firo-electrumx/package.json | 2 +- .../tests/firoElectrumxNetwork.spec.ts | 74 +++++++++++-------- .../tests/mocked/electrumxSocket.mock.ts | 13 +--- services/guard-service/config/default.yaml | 2 +- services/guard-service/config/test.yaml | 2 +- .../src/configs/guardsFiroConfigs.ts | 2 +- 8 files changed, 61 insertions(+), 56 deletions(-) diff --git a/packages/networks/firo-electrumx/README.md b/packages/networks/firo-electrumx/README.md index 575531e2c..5e5fcec81 100644 --- a/packages/networks/firo-electrumx/README.md +++ b/packages/networks/firo-electrumx/README.md @@ -9,7 +9,7 @@ ## Introduction -A package to be used as network api provider for @rosen-chains/firo package, using ElectrumX TCP JSON-RPC protocol. +A package to be used as network api provider for @rosen-chains/firo package, using ElectrumX TLS JSON-RPC protocol. ## Installation diff --git a/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts b/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts index 55301798d..bc4ee2c97 100644 --- a/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts +++ b/packages/networks/firo-electrumx/lib/firoElectrumxNetwork.ts @@ -1,6 +1,6 @@ import { Psbt } from 'bitcoinjs-lib'; import * as crypto from 'crypto'; -import * as net from 'net'; +import * as tls from 'tls'; import { AbstractLogger } from '@rosen-bridge/abstract-logger'; import { @@ -133,7 +133,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { txId: string, ) => Promise; - private socket: net.Socket | null = null; + private socket: tls.TLSSocket | null = null; private responseBuffer = ''; private pendingRequests: Map< number, @@ -165,7 +165,11 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { private doConnect = (): Promise => { return new Promise((resolve, reject) => { - const socket = net.createConnection(this.port, this.host); + const socket = tls.connect({ + host: this.host, + port: this.port, + servername: this.host, + }); socket.setEncoding('utf-8'); socket.setNoDelay(true); socket.setTimeout(this.timeout); @@ -204,7 +208,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { }); }); - socket.once('connect', () => { + socket.once('secureConnect', () => { // Send server.version handshake socket.write( JSON.stringify({ @@ -234,7 +238,7 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { }); }; - private setupSocketListeners = (socket: net.Socket) => { + private setupSocketListeners = (socket: tls.TLSSocket) => { socket.on('data', (data: string) => { this.responseBuffer += data; const lines = this.responseBuffer.split('\n'); @@ -268,21 +272,21 @@ class FiroElectrumXNetwork extends AbstractFiroNetwork { this.socket = null; this.serverVersionSent = false; this.rejectAllPending( - new NetworkError(`TCP socket error: ${err.message}`), + new NetworkError(`TLS socket error: ${err.message}`), ); }); socket.on('close', () => { this.socket = null; this.serverVersionSent = false; - this.rejectAllPending(new NetworkError('TCP connection closed')); + this.rejectAllPending(new NetworkError('TLS connection closed')); }); socket.on('timeout', () => { socket.destroy(); this.socket = null; this.serverVersionSent = false; - this.rejectAllPending(new NetworkError('TCP connection timeout')); + this.rejectAllPending(new NetworkError('TLS connection timeout')); }); }; diff --git a/packages/networks/firo-electrumx/package.json b/packages/networks/firo-electrumx/package.json index 5b919ee0f..b2958c53a 100644 --- a/packages/networks/firo-electrumx/package.json +++ b/packages/networks/firo-electrumx/package.json @@ -1,7 +1,7 @@ { "name": "@rosen-chains/firo-electrumx", "version": "0.0.0", - "description": "A package to be used as network api provider for @rosen-chains/firo package using ElectrumX TCP API", + "description": "A package to be used as network api provider for @rosen-chains/firo package using ElectrumX TLS API", "keywords": [ "rosen" ], diff --git a/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts b/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts index a9a028ddb..22b0e3453 100644 --- a/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts +++ b/packages/networks/firo-electrumx/tests/firoElectrumxNetwork.spec.ts @@ -10,12 +10,24 @@ import { import FiroElectrumXNetwork, { addressToScripthash, } from '../lib/firoElectrumxNetwork'; -import { setMockResponses, resetMock } from './mocked/electrumxSocket.mock'; +import { + createMockSocket, + setMockResponses, + resetMock, +} from './mocked/electrumxSocket.mock'; import * as testData from './testData'; +vi.mock('tls', () => ({ + connect: vi.fn(() => { + const socket = createMockSocket(); + setTimeout(() => socket.emit('secureConnect'), 0); + return socket; + }), +})); + describe('FiroElectrumXNetwork', () => { const HOST = '127.0.0.1'; - const PORT = 50001; + const PORT = 50002; const mockGetSavedTransactionById = vi.fn().mockReturnValue(undefined); const createNetwork = () => new FiroElectrumXNetwork(HOST, PORT, mockGetSavedTransactionById); @@ -56,7 +68,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getHeight` should return block height successfully * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX blockchain.headers.subscribe response * - create new instance of FiroElectrumXNetwork @@ -74,17 +86,17 @@ describe('FiroElectrumXNetwork', () => { }); /** - * @target `FiroElectrumXNetwork.getHeight` should throw NetworkError on TCP error + * @target `FiroElectrumXNetwork.getHeight` should throw NetworkError on TLS error * @dependencies - * - net (TCP) + * - tls * @scenario - * - mock TCP connection to fail + * - mock TLS connection to fail * @expected * - it should throw NetworkError */ - it('should throw NetworkError on TCP error', async () => { - const { createConnection } = await import('net'); - vi.mocked(createConnection).mockImplementationOnce(() => { + it('should throw NetworkError on TLS error', async () => { + const { connect } = await import('tls'); + vi.mocked(connect).mockImplementationOnce(() => { throw new Error('Connection refused'); }); @@ -97,7 +109,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getBlockTransactionIds` should return block tx ids * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX blockchain.block.txids response * - pre-populate hash→height cache via getBlockInfo/resolveHeight @@ -123,7 +135,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getBlockInfo` should return block info * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX blockchain.block.header response * - pre-populate hash→height cache @@ -155,7 +167,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getTransaction` should return transaction * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX blockchain.transaction.get response * @expected @@ -206,7 +218,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.isBoxUnspentAndValid` should return true for unspent output * @dependencies - * - net (TCP) + * - tls * @scenario * - mock transaction hex and listunspent containing the box * @expected @@ -234,7 +246,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.isBoxUnspentAndValid` should return false for spent output * @dependencies - * - net (TCP) + * - tls * @scenario * - mock transaction hex but listunspent not containing the box * @expected @@ -255,7 +267,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.isBoxUnspentAndValid` should return false when tx doesn't exist * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX to return error for non-existent tx * @expected @@ -280,7 +292,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getUtxo` should return UTXO data * @dependencies - * - net (TCP) + * - tls * @scenario * - mock transaction hex with the requested output * @expected @@ -300,7 +312,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getUtxo` should throw FailedError for invalid output index * @dependencies - * - net (TCP) + * - tls * @scenario * - mock transaction hex, request non-existent output index * @expected @@ -320,7 +332,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getFeeRatio` should return fee ratio * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX blockchain.estimatefee response * @expected @@ -343,7 +355,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.isTxInMempool` should return true when tx is in mempool * @dependencies - * - net (TCP) + * - tls * @scenario * - mock verbose get to return an existing tx without confirmations * @expected @@ -361,7 +373,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.isTxInMempool` should return false when tx is confirmed * @dependencies - * - net (TCP) + * - tls * @scenario * - mock verbose get to return a positive confirmation count * @expected @@ -385,7 +397,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.isTxInMempool` should return false when tx is not found * @dependencies - * - net (TCP) + * - tls * @scenario * - mock verbose get to fail * @expected @@ -405,7 +417,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getTransactionHex` should return transaction hex * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX blockchain.transaction.get response * @expected @@ -425,7 +437,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.submitTransaction` should submit transaction * @dependencies - * - net (TCP), bitcoinjs-lib Psbt + * - tls, bitcoinjs-lib Psbt * @scenario * - mock Psbt for transaction extraction * - mock ElectrumX broadcast response @@ -454,7 +466,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getAddressBoxes` should return address UTXOs with pagination * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX listunspent response * @expected @@ -472,7 +484,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getAddressBoxes` should handle empty address * @dependencies - * - net (TCP) + * - tls * @scenario * - mock empty listunspent response * @expected @@ -492,7 +504,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getTxConfirmation` should return confirmation count * @dependencies - * - net (TCP) + * - tls * @scenario * - mock verbose get to return Firo Core confirmation count * @expected @@ -516,7 +528,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getTxConfirmation` should return -1 for unconfirmed tx * @dependencies - * - net (TCP) + * - tls * @scenario * - mock verbose get to return an existing tx without confirmations * @expected @@ -534,7 +546,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getTxConfirmation` should return -1 when tx not found * @dependencies - * - net (TCP) + * - tls * @scenario * - mock verbose get to throw * @expected @@ -552,7 +564,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getTxConfirmation` should handle tx with unsigned hash * @dependencies - * - net (TCP), bitcoinjs-lib Psbt + * - tls, bitcoinjs-lib Psbt * @scenario * - create custom getSavedTransactionById returning a payment tx * - mock direct PSBT extraction success @@ -615,7 +627,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getAddressAssets` should return address balance * @dependencies - * - net (TCP) + * - tls * @scenario * - mock ElectrumX blockchain.scripthash.get_balance response * @expected @@ -634,7 +646,7 @@ describe('FiroElectrumXNetwork', () => { /** * @target `FiroElectrumXNetwork.getAddressAssets` should return 0 for empty address * @dependencies - * - net (TCP) + * - tls * @scenario * - mock zero balance response * @expected diff --git a/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts b/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts index 16d729da9..c136bd330 100644 --- a/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts +++ b/packages/networks/firo-electrumx/tests/mocked/electrumxSocket.mock.ts @@ -11,7 +11,7 @@ export function resetMock() { mockResponses = []; } -function createMockSocket() { +export function createMockSocket() { const socket = new EventEmitter() as EventEmitter & { written: string[]; destroyed: boolean; @@ -87,14 +87,3 @@ function createMockSocket() { return socket; } - -// Mock the 'net' module -vi.mock('net', () => { - return { - createConnection: vi.fn(() => { - const socket = createMockSocket(); - setTimeout(() => socket.emit('connect'), 0); - return socket; - }), - }; -}); diff --git a/services/guard-service/config/default.yaml b/services/guard-service/config/default.yaml index 0ce9af158..83ae23163 100644 --- a/services/guard-service/config/default.yaml +++ b/services/guard-service/config/default.yaml @@ -70,7 +70,7 @@ bitcoinRunes: firo: electrumx: host: '127.0.0.1' - port: 50001 + port: 50002 timeout: 30 bankPublicKey: '' # corresponding public key of lock address txFeeSlippage: 20 # allowed slippage percentage for tx fees diff --git a/services/guard-service/config/test.yaml b/services/guard-service/config/test.yaml index cf2fcdebf..76d7fd396 100644 --- a/services/guard-service/config/test.yaml +++ b/services/guard-service/config/test.yaml @@ -38,7 +38,7 @@ bitcoin: firo: electrumx: host: '127.0.0.1' - port: 50001 + port: 50002 timeout: 30 bankPublicKey: 'bcb07faa6c0f19e2f2587aa9ef6f43a68fc0135321216a71dc87c8527af4ca6a' txFeeSlippage: 20 diff --git a/services/guard-service/src/configs/guardsFiroConfigs.ts b/services/guard-service/src/configs/guardsFiroConfigs.ts index f21403a4b..119c899e4 100644 --- a/services/guard-service/src/configs/guardsFiroConfigs.ts +++ b/services/guard-service/src/configs/guardsFiroConfigs.ts @@ -13,7 +13,7 @@ class GuardsFiroConfigs { : '127.0.0.1', port: config.has('firo.electrumx.port') ? config.get('firo.electrumx.port') - : 50001, + : 50002, timeout: config.has('firo.electrumx.timeout') ? config.get('firo.electrumx.timeout') : 30,