import type { RequestResponse} from "@otoplo/electrum-client"; import { ConnectionStatus, ElectrumClient, TransportScheme } from "@otoplo/electrum-client"; import type { BlockTip, IFirstUse, IListUnspentRecord, ITokenGenesis, ITokenListUnspent, ITokensBalance, ITokenUtxo, ITransaction, ITXHistory, IUtxo, RostrumParams, ServerFeatures } from "../types/rostrum.types"; import type { Balance } from "../types/wallet.types"; import { isTestnet } from "../utils/common"; import type { KVStore } from "../persistence/datastore/kv"; type RPCParameter = object | string | number | boolean | null; export class RostrumService { private readonly kvStore: KVStore; private client?: ElectrumClient; public constructor(kvStore: KVStore) { this.kvStore = kvStore; } public getFeatures(): Promise { return this.execute('server.features'); } public getBlockTip(): Promise { return this.execute('blockchain.headers.tip'); } public getBalance(address: string): Promise { return this.execute('blockchain.address.get_balance', address, 'exclude_tokens'); } public getTransactionsHistory(address: string, fromHeight = 0): Promise { return this.execute('blockchain.address.get_history', address, { from_height: fromHeight }); } public getFirstUse(address: string): Promise { return this.execute('blockchain.address.get_first_use', address); } public async isAddressUsed(address: string): Promise { try { const firstUse = await this.getFirstUse(address); return !!(firstUse.tx_hash && firstUse.tx_hash !== ""); } catch (e) { if (e instanceof Error && e.message.includes("not found")) { return false; } throw e; } } public getTransaction(id: string, verbose = true): Promise { return this.execute('blockchain.transaction.get', id, verbose); } public getUtxo(outpoint: string): Promise { return this.execute('blockchain.utxo.get', outpoint); } public getNexaUtxos(address: string): Promise { return this.execute('blockchain.address.listunspent', address, 'exclude_tokens'); } public async getTokenUtxos(address: string, token: string): Promise { const listunspent = await this.execute('token.address.listunspent', address, null, token); return listunspent.unspent; } public async getTokensBalance(address: string): Promise> { const tokensBalance = await this.execute('token.address.get_balance', address); const balance: Record = {}; for (const cToken in tokensBalance.confirmed) { if (tokensBalance.confirmed[cToken] != 0) { balance[cToken] = { confirmed: BigInt(tokensBalance.confirmed[cToken]).toString(), unconfirmed: "0" } } } for (const uToken in tokensBalance.unconfirmed) { if (tokensBalance.unconfirmed[uToken] != 0) { if (balance[uToken]) { balance[uToken].unconfirmed = BigInt(tokensBalance.unconfirmed[uToken]).toString(); } else { balance[uToken] = { confirmed: "0", unconfirmed: BigInt(tokensBalance.unconfirmed[uToken]).toString() } } } } return balance; } public getTokenGenesis(token: string): Promise { return this.execute('token.genesis.info', token); } public broadcast(txHex: string): Promise { return this.execute('blockchain.transaction.broadcast', txHex); } public subscribeHeaders(handler: (block: number) => void): Promise { return this.client!.subscribe((response: any) => { const data = Array.isArray(response) ? response[0] : response; const height = typeof data?.height === 'number' ? data.height : 0; handler(height); }, 'blockchain.headers.subscribe'); } public subscribeAddress(address: string, handler: (data: unknown) => void): Promise { return this.client!.subscribe(handler, 'blockchain.address.subscribe', address); } public async getLatency(): Promise { try { const start = Date.now(); const res = await this.getBlockTip(); if (res) { return Date.now() - start; } return 0; } catch { return 0; } } public async connect(params?: RostrumParams): Promise { try { if (!params) { params = await this.getCurrentInstance(); } this.client = new ElectrumClient("com.otoplo.wallet", "1.4.3", params.host, params.port, params.scheme, 45*1000, 10*1000); await this.client.connect(); } catch (e) { if (e instanceof Error) { console.info(e.message); } else { console.error(e); } throw e; } } public async disconnect(force?: boolean): Promise { try { return await this.client?.disconnect(force) ?? false; } catch (e) { console.error(e) return false; } } private async execute(method: string, ...parameters: RPCParameter[]): Promise { await this.waitForConnection(); const res = await this.client!.request(method, ...parameters); if (res instanceof Error) { throw res; } return res as T; } private waitForConnection(timeout = 5000): Promise { const start = Date.now(); return new Promise((resolve, reject) => { const check = (): void => { if (this.client?.connectionStatus == ConnectionStatus.CONNECTED) { return resolve(); } if (Date.now() - start > timeout) { return reject(new Error("Rostrum Connection timeout")); } setTimeout(check, 250); }; check(); }); } public async getCurrentInstance(): Promise { const params = await this.kvStore.getRostrumParams(); if (params) { return params; } return RostrumService.getPredefinedInstances()[0]; } public static getPredefinedInstances(): RostrumParams[] { if (isTestnet()) { return [ { scheme: TransportScheme.WSS, host: 'testnet-electrum.nexa.org', port: 30004, label: 'NexaOrg' } ]; } else { return [ { scheme: TransportScheme.WSS, host: 'rostrum.otoplo.com', port: 443, label: 'Otoplo' }, { scheme: TransportScheme.WSS, host: 'electrum.nexa.org', port: 20004, label: 'NexaOrg' } ]; } } }