From 7ff32820b9dc6b95a941297fd42ab3ab747cfb83 Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 26 Oct 2026 12:32:40 -0700 Subject: [PATCH 01/10] Broadcast transactions through Electrum servers BlockbookElectrum now sends blockchain.transaction.broadcast instead of throwing, so an electrumws(s) connection joins the broadcast like any Blockbook socket. The server's txid is returned as Blockbook's result, and a rejection surfaces the server's reason. --- .../utxobased/network/BlockbookElectrum.ts | 9 +- src/common/utxobased/network/electrumApi.ts | 16 +++ .../network/BlockbookElectrum.spec.ts | 107 ++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 test/common/utxobased/network/BlockbookElectrum.spec.ts diff --git a/src/common/utxobased/network/BlockbookElectrum.ts b/src/common/utxobased/network/BlockbookElectrum.ts index 5d854b4a..14301c52 100644 --- a/src/common/utxobased/network/BlockbookElectrum.ts +++ b/src/common/utxobased/network/BlockbookElectrum.ts @@ -19,6 +19,7 @@ import { AddressResponse, BlockbookAccountUtxo } from './blockbookApi' import { addressTransactionsMessage, AddressTransactionsResponse, + broadcastTransactionMessage, listUnspentMessage, ListUnspentResponse, pingMessage @@ -191,8 +192,12 @@ export function makeBlockbookElectrum( walletId }) - instance.broadcastTx = async () => { - throw new Error('broadcastTx not supported for Electrum connections') + instance.broadcastTx = async transaction => { + const txid = await instance.promisifyWsMessage( + broadcastTransactionMessage(transaction.signedTx) + ) + // Blockbook reports the broadcast txid as `result`: + return { result: txid } } instance.fetchAddress = async () => { throw new Error('fetchAddress not supported for Electrum connections') diff --git a/src/common/utxobased/network/electrumApi.ts b/src/common/utxobased/network/electrumApi.ts index e89ea1a3..59a186a3 100644 --- a/src/common/utxobased/network/electrumApi.ts +++ b/src/common/utxobased/network/electrumApi.ts @@ -201,3 +201,19 @@ export const transactionMessage = ( }) export type TransactionResponse = ElectrumTransaction export const asTransactionResponse = asElectrumTransaction + +/** + * Broadcast Transaction + */ +export const broadcastTransactionMessage = ( + rawTx: string +): ElectrumTask => ({ + method: 'blockchain.transaction.broadcast', + params: [rawTx], + cleaner: asElectrumResponse(asBroadcastTransactionResponse) +}) +export type BroadcastTransactionResponse = ReturnType< + typeof asBroadcastTransactionResponse +> +// The server answers with the transaction's txid: +export const asBroadcastTransactionResponse = asString diff --git a/test/common/utxobased/network/BlockbookElectrum.spec.ts b/test/common/utxobased/network/BlockbookElectrum.spec.ts new file mode 100644 index 00000000..a41da06d --- /dev/null +++ b/test/common/utxobased/network/BlockbookElectrum.spec.ts @@ -0,0 +1,107 @@ +import { expect } from 'chai' +import { EdgeTransaction } from 'edge-core-js/types' +import WS from 'ws' + +import { EngineEmitter } from '../../../../src/common/plugin/EngineEmitter' +import { info as bitcoin } from '../../../../src/common/utxobased/info/bitcoin' +import { Blockbook } from '../../../../src/common/utxobased/network/Blockbook' +import { makeBlockbookElectrum } from '../../../../src/common/utxobased/network/BlockbookElectrum' +import { WsTask } from '../../../../src/common/utxobased/network/Socket' +import { SocketEmitter } from '../../../../src/common/utxobased/network/SocketEmitter' +import { makeFakeLog } from '../../../utils' + +const SIGNED_TX = + '02000000000101955c9281f8853c0b051e1c1bfe5c90072ca59208676eba7a199191eacbdabd450000000000fdffffff01443217000000000017a914f01eb338a6c45ce9986d0528898c5fd747bb97db8700000000' +const TXID = '512ae0bea5252fbcde7783dbbbf6c505c040b7a138645987922489f8d84ac6de' + +const transaction = ({ + txid: TXID, + signedTx: SIGNED_TX +} as unknown) as EdgeTransaction + +interface ElectrumRequest { + id: string + method: string + params: unknown +} + +describe('BlockbookElectrum with a dummy Electrum server', function () { + let websocketServer: WS.Server + let blockbook: Blockbook + let requests: ElectrumRequest[] + // Builds the JSON-RPC answer body (`result` or `error`) for a request: + let answer: (request: ElectrumRequest) => object + + beforeEach(async () => { + requests = [] + answer = () => ({ result: null }) + websocketServer = new WS.Server({ port: 8556 }) + websocketServer.on('connection', (ws: WebSocket) => { + ws.onmessage = event => { + const request: ElectrumRequest = JSON.parse(event.data) + requests.push(request) + const body = + request.method === 'server.ping' ? { result: null } : answer(request) + ws.send(JSON.stringify({ jsonrpc: '2.0', id: request.id, ...body })) + } + }) + + async function* taskGeneratorFn( + _uri: string + ): AsyncGenerator | boolean> { + return false + } + + blockbook = makeBlockbookElectrum({ + connectionUri: 'electrumws://localhost:8556', + engineEmitter: new EngineEmitter(), + initOptions: {}, + log: makeFakeLog(), + pluginInfo: bitcoin, + socketEmitter: new SocketEmitter(), + taskGeneratorFn, + walletId: '' + }) + await blockbook.connect() + }) + + afterEach(async () => { + await blockbook.disconnect() + websocketServer.close() + }) + + it('broadcasts with blockchain.transaction.broadcast', async () => { + answer = () => ({ result: TXID }) + + const response = await blockbook.broadcastTx(transaction) + + expect(response).to.deep.equal({ result: TXID }) + const broadcasts = requests.filter( + request => request.method === 'blockchain.transaction.broadcast' + ) + expect(broadcasts.map(request => request.params)).to.deep.equal([ + [SIGNED_TX] + ]) + }) + + it('rejects with the reason the server gives', async () => { + answer = () => ({ + error: { + code: 1, + message: 'the transaction was rejected by network rules.' + } + }) + + let error: unknown + try { + await blockbook.broadcastTx(transaction) + } catch (e) { + error = e + } + + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.equal( + 'the transaction was rejected by network rules.' + ) + }) +}) From 468c42036d4685d6e5cde6b46608e63253c05a95 Mon Sep 17 00:00:00 2001 From: peachbits Date: Tue, 27 Oct 2026 10:47:00 -0700 Subject: [PATCH 02/10] Send Electrum pings with positional params Fulcrum rejects server.ping when the socket's `{}` default arrives as named params, which closed the connection right after every ping and kept Electrum-only wallets reconnecting. --- src/common/utxobased/network/electrumApi.ts | 4 +++- .../network/BlockbookElectrum.spec.ts | 22 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/common/utxobased/network/electrumApi.ts b/src/common/utxobased/network/electrumApi.ts index 59a186a3..145d2216 100644 --- a/src/common/utxobased/network/electrumApi.ts +++ b/src/common/utxobased/network/electrumApi.ts @@ -144,7 +144,9 @@ export const asElectrumResponse = (asT: Cleaner): Cleaner => raw => { export const pingMessage = (): ElectrumTask => { return { method: 'server.ping', - params: undefined, + // Electrum servers such as Fulcrum reject the `{}` the socket sends for + // missing params as named params, so an empty array is required: + params: [], cleaner: asElectrumResponse(asPingResponse) } } diff --git a/test/common/utxobased/network/BlockbookElectrum.spec.ts b/test/common/utxobased/network/BlockbookElectrum.spec.ts index a41da06d..1fe3160a 100644 --- a/test/common/utxobased/network/BlockbookElectrum.spec.ts +++ b/test/common/utxobased/network/BlockbookElectrum.spec.ts @@ -6,6 +6,7 @@ import { EngineEmitter } from '../../../../src/common/plugin/EngineEmitter' import { info as bitcoin } from '../../../../src/common/utxobased/info/bitcoin' import { Blockbook } from '../../../../src/common/utxobased/network/Blockbook' import { makeBlockbookElectrum } from '../../../../src/common/utxobased/network/BlockbookElectrum' +import { pingMessage } from '../../../../src/common/utxobased/network/electrumApi' import { WsTask } from '../../../../src/common/utxobased/network/Socket' import { SocketEmitter } from '../../../../src/common/utxobased/network/SocketEmitter' import { makeFakeLog } from '../../../utils' @@ -40,8 +41,18 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { ws.onmessage = event => { const request: ElectrumRequest = JSON.parse(event.data) requests.push(request) + // Fulcrum rejects named (object) params for server.ping: const body = - request.method === 'server.ping' ? { result: null } : answer(request) + request.method !== 'server.ping' + ? answer(request) + : Array.isArray(request.params) + ? { result: null } + : { + error: { + code: -32602, + message: 'Named params are not supported for this method' + } + } ws.send(JSON.stringify({ jsonrpc: '2.0', id: request.id, ...body })) } }) @@ -70,6 +81,15 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { websocketServer.close() }) + it('pings with positional params', async () => { + await blockbook.promisifyWsMessage(pingMessage()) + + // The socket's own keepalive may have pinged too: + const pings = requests.filter(request => request.method === 'server.ping') + expect(pings).to.not.have.length(0) + for (const ping of pings) expect(ping.params).to.deep.equal([]) + }) + it('broadcasts with blockchain.transaction.broadcast', async () => { answer = () => ({ result: TXID }) From 78df09899f15e8d19d3b42c767c80502157524dd Mon Sep 17 00:00:00 2001 From: peachbits Date: Tue, 27 Oct 2026 10:49:33 -0700 Subject: [PATCH 03/10] Track block height through Electrum servers fetchInfo reads the chain tip with blockchain.headers.subscribe, and watchBlocks subscribes to the same method so each new header reaches the engine as a block height change. Electrum notifications carry the method in place of an id, which is the id the socket files subscriptions under, and the subscription's first answer counts as its acknowledgement. fetchInfo's request already subscribes the connection, so a block can be announced before watchBlocks files the subscription. Those announcements are skipped, since the socket drops a connection over a message it cannot route. --- src/common/utxobased/network/Blockbook.ts | 4 +- .../utxobased/network/BlockbookElectrum.ts | 86 ++++++++++++++++--- src/common/utxobased/network/electrumApi.ts | 17 ++++ .../network/BlockbookElectrum.spec.ts | 67 ++++++++++++++- 4 files changed, 160 insertions(+), 14 deletions(-) diff --git a/src/common/utxobased/network/Blockbook.ts b/src/common/utxobased/network/Blockbook.ts index dc520906..4181aa18 100644 --- a/src/common/utxobased/network/Blockbook.ts +++ b/src/common/utxobased/network/Blockbook.ts @@ -103,6 +103,8 @@ interface BlockbookConfig { log: EdgeLog taskGeneratorFn: TaskGeneratorFn ping?: () => Promise + /** The new-block subscription, for servers that are not Blockbook. */ + subscribeNewBlockMessage?: () => BlockbookTask socketEmitter: SocketEmitter walletId: string /** Socket timing overrides, for tests. */ @@ -232,7 +234,7 @@ export function makeBlockbook(config: BlockbookConfig): Blockbook { ) } socket.subscribe({ - ...subscribeNewBlockMessage(), + ...(config.subscribeNewBlockMessage ?? subscribeNewBlockMessage)(), cb: socketCb, deferred: deferredBlockSub, subscribed: false diff --git a/src/common/utxobased/network/BlockbookElectrum.ts b/src/common/utxobased/network/BlockbookElectrum.ts index 14301c52..3c0fdcdc 100644 --- a/src/common/utxobased/network/BlockbookElectrum.ts +++ b/src/common/utxobased/network/BlockbookElectrum.ts @@ -1,5 +1,6 @@ import { crypto } from 'altcoin-js' import { + asArray, asEither, asJSON, asObject, @@ -15,11 +16,17 @@ import { PluginInfo } from '../../plugin/types' import { UtxoInitOptions } from '../engine/types' import { addressToScriptPubkey } from '../keymanager/keymanager' import { Blockbook, makeBlockbook } from './Blockbook' -import { AddressResponse, BlockbookAccountUtxo } from './blockbookApi' +import { + AddressResponse, + BlockbookAccountUtxo, + SubscribeNewBlockResponse +} from './blockbookApi' import { addressTransactionsMessage, AddressTransactionsResponse, + asHeadersSubscribeResponse, broadcastTransactionMessage, + headersSubscribeMessage, listUnspentMessage, ListUnspentResponse, pingMessage @@ -50,8 +57,8 @@ export interface ElectrumResponseMessage { error: { message: string } | undefined } -const asResponseMessage = (raw: unknown): WsResponseMessage => { - const message = asJSON( +const asElectrumMessage = asJSON( + asEither( asObject({ id: asString, result: asUnknown, @@ -61,8 +68,30 @@ const asResponseMessage = (raw: unknown): WsResponseMessage => { asObject({ connected: asString }) ) ) + }), + // A subscription's notifications carry its method in place of an id: + asObject({ + method: asString, + params: asArray(asUnknown) }) - )(raw) + ) +) + +const { method: headersSubscribeMethod } = headersSubscribeMessage() + +const asResponseMessage = (raw: unknown): WsResponseMessage => { + const message = asElectrumMessage(raw) + + // The socket files each subscription under its method name: + if ('method' in message) { + return { id: message.method, data: message.params[0] } + } + + // Electrum answers a subscription with the current header, not the + // acknowledgement the socket waits for. fetchInfo reports that height: + if (message.id === headersSubscribeMethod && message.error == null) { + return { id: message.id, data: { subscribed: true } } + } return { id: message.id, @@ -71,11 +100,20 @@ const asResponseMessage = (raw: unknown): WsResponseMessage => { } } -const asResponse: Cleaner = (raw: unknown) => { +const makeAsResponse = ( + isWatchingBlocks: () => boolean +): Cleaner => (raw: unknown) => { const payloadString = asString(raw).trimEnd() const messageStrings = payloadString.split('\n') const messages = messageStrings.map(asResponseMessage) - return messages + + // fetchInfo's blockchain.headers.subscribe subscribes the connection to new + // blocks, so the server can announce one before watchBlocks files the + // subscription. The socket drops connections over messages it cannot route, + // so announcements until then are skipped. watchBlocks reports each block + // after it subscribes: + if (isWatchingBlocks()) return messages + return messages.filter(message => message.id !== headersSubscribeMethod) } export function makeBlockbookElectrum( @@ -104,9 +142,10 @@ export function makeBlockbookElectrum( return scriptHash } + let isWatchingBlocks = false const instance = makeBlockbook({ asAddress, - asResponse, + asResponse: makeAsResponse(() => isWatchingBlocks), connectionUri, engineEmitter, initOptions, @@ -188,6 +227,14 @@ export function makeBlockbookElectrum( ping: async (): Promise => { return await instance.promisifyWsMessage(pingMessage()) }, + subscribeNewBlockMessage: () => ({ + ...headersSubscribeMessage(), + // Notifications hold the header itself, with no response envelope: + cleaner: (raw: unknown): SubscribeNewBlockResponse => { + const header = asHeadersSubscribeResponse(raw) + return { height: header.height, hash: headerToBlockHash(header.hex) } + } + }), socketEmitter, walletId }) @@ -206,18 +253,35 @@ export function makeBlockbookElectrum( throw new Error('fetchAddressUtxos not supported for Electrum connections') } instance.fetchInfo = async () => { - throw new Error('fetchInfo not supported for Electrum connections') + const header = await instance.promisifyWsMessage(headersSubscribeMessage()) + return { + name: pluginInfo.currencyInfo.displayName, // unused + shortcut: pluginInfo.currencyInfo.currencyCode, // unused + decimals: 8, // unused + version: '', // unused + bestHeight: header.height, + bestHash: headerToBlockHash(header.hex), + block0Hash: '', // unused + testnet: false, // unused + backend: undefined // unused + } } instance.fetchTransaction = async () => { throw new Error('fetchTransaction not supported for Electrum connections') } - instance.watchAddresses = async () => { - return // Fail gracefully + const { watchBlocks } = instance + instance.watchBlocks = deferredBlockSub => { + isWatchingBlocks = true + watchBlocks(deferredBlockSub) } - instance.watchBlocks = async () => { + instance.watchAddresses = async () => { return // Fail gracefully } return instance } + +// A block's hash is its header's double SHA-256, in reverse byte order: +const headerToBlockHash = (headerHex: string): string => + crypto.hash256(Buffer.from(headerHex, 'hex')).reverse().toString('hex') diff --git a/src/common/utxobased/network/electrumApi.ts b/src/common/utxobased/network/electrumApi.ts index 145d2216..bbd40760 100644 --- a/src/common/utxobased/network/electrumApi.ts +++ b/src/common/utxobased/network/electrumApi.ts @@ -153,6 +153,23 @@ export const pingMessage = (): ElectrumTask => { export type PingResponse = ReturnType export const asPingResponse = asMaybe(asUndefined) +/** + * Subscribe to Headers (Get Block Height) + */ +export const headersSubscribeMessage = (): ElectrumTask => ({ + method: 'blockchain.headers.subscribe', + params: [], + cleaner: asElectrumResponse(asHeadersSubscribeResponse) +}) +export type HeadersSubscribeResponse = ReturnType< + typeof asHeadersSubscribeResponse +> +// The chain tip's header, which the server also sends for each new tip: +export const asHeadersSubscribeResponse = asObject({ + height: asNumber, + hex: asString +}) + /** * List Unspent (Get UTXOs) */ diff --git a/test/common/utxobased/network/BlockbookElectrum.spec.ts b/test/common/utxobased/network/BlockbookElectrum.spec.ts index 1fe3160a..6c15c2f9 100644 --- a/test/common/utxobased/network/BlockbookElectrum.spec.ts +++ b/test/common/utxobased/network/BlockbookElectrum.spec.ts @@ -2,10 +2,14 @@ import { expect } from 'chai' import { EdgeTransaction } from 'edge-core-js/types' import WS from 'ws' -import { EngineEmitter } from '../../../../src/common/plugin/EngineEmitter' +import { + EngineEmitter, + EngineEvent +} from '../../../../src/common/plugin/EngineEmitter' import { info as bitcoin } from '../../../../src/common/utxobased/info/bitcoin' import { Blockbook } from '../../../../src/common/utxobased/network/Blockbook' import { makeBlockbookElectrum } from '../../../../src/common/utxobased/network/BlockbookElectrum' +import Deferred from '../../../../src/common/utxobased/network/Deferred' import { pingMessage } from '../../../../src/common/utxobased/network/electrumApi' import { WsTask } from '../../../../src/common/utxobased/network/Socket' import { SocketEmitter } from '../../../../src/common/utxobased/network/SocketEmitter' @@ -14,6 +18,11 @@ import { makeFakeLog } from '../../../utils' const SIGNED_TX = '02000000000101955c9281f8853c0b051e1c1bfe5c90072ca59208676eba7a199191eacbdabd450000000000fdffffff01443217000000000017a914f01eb338a6c45ce9986d0528898c5fd747bb97db8700000000' const TXID = '512ae0bea5252fbcde7783dbbbf6c505c040b7a138645987922489f8d84ac6de' +// Bitcoin's genesis block header and hash: +const HEADER = + '0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c' +const HEADER_HASH = + '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f' const transaction = ({ txid: TXID, @@ -28,7 +37,9 @@ interface ElectrumRequest { describe('BlockbookElectrum with a dummy Electrum server', function () { let websocketServer: WS.Server + let client: WebSocket let blockbook: Blockbook + let engineEmitter: EngineEmitter let requests: ElectrumRequest[] // Builds the JSON-RPC answer body (`result` or `error`) for a request: let answer: (request: ElectrumRequest) => object @@ -36,8 +47,10 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { beforeEach(async () => { requests = [] answer = () => ({ result: null }) + engineEmitter = new EngineEmitter() websocketServer = new WS.Server({ port: 8556 }) websocketServer.on('connection', (ws: WebSocket) => { + client = ws ws.onmessage = event => { const request: ElectrumRequest = JSON.parse(event.data) requests.push(request) @@ -65,7 +78,7 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { blockbook = makeBlockbookElectrum({ connectionUri: 'electrumws://localhost:8556', - engineEmitter: new EngineEmitter(), + engineEmitter, initOptions: {}, log: makeFakeLog(), pluginInfo: bitcoin, @@ -90,6 +103,56 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { for (const ping of pings) expect(ping.params).to.deep.equal([]) }) + it('fetches the block height with blockchain.headers.subscribe', async () => { + answer = () => ({ result: { height: 974024, hex: HEADER } }) + + const info = await blockbook.fetchInfo() + + expect(info.bestHeight).to.equal(974024) + expect(info.bestHash).to.equal(HEADER_HASH) + }) + + it('reports each new block the server announces', async () => { + answer = () => ({ result: { height: 974024, hex: HEADER } }) + const subscribed = new Deferred() + blockbook.watchBlocks(subscribed) + await subscribed.promise + + const announcedHeight = new Promise(resolve => { + engineEmitter.on(EngineEvent.BLOCK_HEIGHT_CHANGED, (_uri, height) => + resolve(height) + ) + }) + client.send( + JSON.stringify({ + jsonrpc: '2.0', + method: 'blockchain.headers.subscribe', + params: [{ height: 974025, hex: HEADER }] + }) + ) + + expect(await announcedHeight).to.equal(974025) + // The notification leaves the connection usable: + await blockbook.promisifyWsMessage(pingMessage()) + }) + + it('skips new blocks announced before blocks are watched', async () => { + answer = () => ({ result: { height: 974024, hex: HEADER } }) + // fetchInfo subscribes the connection to new blocks server-side: + await blockbook.fetchInfo() + + client.send( + JSON.stringify({ + jsonrpc: '2.0', + method: 'blockchain.headers.subscribe', + params: [{ height: 974025, hex: HEADER }] + }) + ) + + // The announcement leaves the connection usable: + await blockbook.promisifyWsMessage(pingMessage()) + }) + it('broadcasts with blockchain.transaction.broadcast', async () => { answer = () => ({ result: TXID }) From 31254243720a92c77ee5684a8ce070a6ee70e270 Mon Sep 17 00:00:00 2001 From: peachbits Date: Thu, 29 Oct 2026 13:24:09 -0700 Subject: [PATCH 04/10] Send the requests a task makes after a failed one A task resumes with each response, and after a failed or timed-out request it resumes with the error. The socket sent the next request a task made only after a response. A task that caught a failure and moved on, such as pickNextTask's loop over unconfirmed transactions, never got its next request sent, and the item that request was for had already left its cache. Failures now continue the task the same way responses do. --- src/common/utxobased/network/Socket.ts | 46 +++++++++++++++----------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/common/utxobased/network/Socket.ts b/src/common/utxobased/network/Socket.ts index 179c548c..04017566 100644 --- a/src/common/utxobased/network/Socket.ts +++ b/src/common/utxobased/network/Socket.ts @@ -267,6 +267,23 @@ export function makeSocket(uri: string, config: SocketConfig): Socket { } } + // A task resumes with each response or failure, and then makes its next + // request or reports with a boolean that it is done: + const submitNextTask = ( + generator: WsTaskAsyncGenerator, + resumed: Promise | boolean, boolean>> + ): void => { + resumed + .then(result => { + const task = result.value + if (typeof task === 'boolean') return + instance.submitTask(task, generator) + }) + .catch(e => { + log.error(e.message) + }) + } + const onTimer = (): void => { log(`socket timer with server ${uri} expired, check if healthCheck needed`) const now = Date.now() - TIMER_SLACK @@ -283,11 +300,10 @@ export function makeSocket(uri: string, config: SocketConfig): Socket { for (const [id, request] of Object.entries(pendingRequests)) { if (request.startTime + timeout < now) { - request.generator - .throw(new Error(`Timeout for request ${id}`)) - .catch(e => { - log.error(e.message) - }) + submitNextTask( + request.generator, + request.generator.throw(new Error(`Timeout for request ${id}`)) + ) removeItem(pendingRequests, id) } } @@ -358,23 +374,13 @@ export function makeSocket(uri: string, config: SocketConfig): Socket { } else { nextValue = responseMessage.data } - request.generator - .next(nextValue) - .then(result => { - const task = result.value - if (typeof task === 'boolean') { - return - } - instance.submitTask(task, request.generator) - }) - .catch(e => { - log.error(e.message) - }) + submitNextTask( + request.generator, + request.generator.next(nextValue) + ) } catch (error) { console.log({ uri, error, response: responseMessage, request }) - request.generator.throw(error).catch(e => { - log.error(e.message) - }) + submitNextTask(request.generator, request.generator.throw(error)) } continue } From d07b69317944e7d41d50a03aefd2ee8f94f56191 Mon Sep 17 00:00:00 2001 From: peachbits Date: Tue, 27 Oct 2026 10:55:18 -0700 Subject: [PATCH 05/10] Fetch transactions through Electrum servers The Electrum translation now answers getTransaction with blockchain.transaction.get, decoding the raw transaction locally, and reads the transaction's height from the history of a script it pays. Servers refuse the history of busy scripts, so each paid script is tried in turn, and when none answers the task fails so the engine retries it. Legacy UTXOs need this for their previous transaction, and unconfirmed transactions need it to pick up their confirmation height on each new block. A failed Electrum request now reaches the task that made it, so that task can retry, and tasks with no Electrum translation fail instead of receiving the previous task's response. --- .../utxobased/network/BlockbookElectrum.ts | 224 ++++++++++++------ src/common/utxobased/network/electrumApi.ts | 13 + .../network/BlockbookElectrum.spec.ts | 156 +++++++++++- 3 files changed, 325 insertions(+), 68 deletions(-) diff --git a/src/common/utxobased/network/BlockbookElectrum.ts b/src/common/utxobased/network/BlockbookElectrum.ts index 3c0fdcdc..2e714dae 100644 --- a/src/common/utxobased/network/BlockbookElectrum.ts +++ b/src/common/utxobased/network/BlockbookElectrum.ts @@ -1,4 +1,4 @@ -import { crypto } from 'altcoin-js' +import { crypto, opcodes, Transaction } from 'altcoin-js' import { asArray, asEither, @@ -19,6 +19,7 @@ import { Blockbook, makeBlockbook } from './Blockbook' import { AddressResponse, BlockbookAccountUtxo, + BlockbookTransaction, SubscribeNewBlockResponse } from './blockbookApi' import { @@ -26,15 +27,19 @@ import { AddressTransactionsResponse, asHeadersSubscribeResponse, broadcastTransactionMessage, + ElectrumTask, headersSubscribeMessage, listUnspentMessage, ListUnspentResponse, - pingMessage + pingMessage, + rawTransactionMessage, + RawTransactionResponse } from './electrumApi' import { TaskGeneratorFn, WsResponse, WsResponseMessage, + WsTask, WsTaskAsyncGenerator } from './Socket' import { SocketEmitter } from './SocketEmitter' @@ -137,9 +142,142 @@ export function makeBlockbookElectrum( address, coin: pluginInfo.coinInfo.name }) - const scriptHashBuffer = crypto.sha256(Buffer.from(scriptPubkey, 'hex')) - const scriptHash = scriptHashBuffer.reverse().toString('hex') - return scriptHash + return toElectrumScriptHash(Buffer.from(scriptPubkey, 'hex')) + } + + // Makes the Electrum requests for a Blockbook task, and returns the + // Blockbook response built from their answers: + function* translateTask( + task: WsTask + ): Generator, unknown, unknown> { + // Translate getAccountUtxo to blockchain.scripthash.listunspent: + if (task.method === 'getAccountUtxo') { + const params = task.params as { descriptor: string } + const address = + asAddress != null ? asAddress(params.descriptor) : params.descriptor + const scriptHash = addressToScriptHash(address) + + // Safe because yielding WsTask should get + // ListUnspentResponse next. + const electrumUtxos = (yield { + ...listUnspentMessage(scriptHash) + }) as ListUnspentResponse + + const blockbookUtxos: BlockbookAccountUtxo[] = electrumUtxos.map( + utxo => ({ + txid: utxo.tx_hash, + vout: utxo.tx_pos, + value: utxo.value.toString(), + height: utxo.height + }) + ) + return blockbookUtxos + } + + // Get Address Transactions: + if (task.method === 'getAccountInfo') { + const params = task.params as { descriptor: string } + const address = + asAddress != null ? asAddress(params.descriptor) : params.descriptor + const scriptHash = addressToScriptHash(address) + + // Safe because yielding WsTask should get + // AddressTransactionsResponse next. + const electrumUtxos = (yield { + ...addressTransactionsMessage(scriptHash) + }) as AddressTransactionsResponse + + const blockbookUtxos: AddressResponse = { + address, // unused + balance: '0', // unused + totalReceived: '0', // unused + totalSent: '0', // unused + txs: electrumUtxos.length, + unconfirmedBalance: '0', // unused + unconfirmedTxs: electrumUtxos.reduce( + (sum, tx) => (sum + tx.height >= 0 ? 1 : 0), + 0 + ), + txids: [], // unused + transactions: [], // TODO: this requires an extra query per txid + page: 0, // unused + totalPages: 1, + itemsOnPage: 0 // unused + } + return blockbookUtxos + } + + // Translate getTransaction to blockchain.transaction.get: + if (task.method === 'getTransaction') { + const params = task.params as { txid: string } + return yield* transactionTask(params.txid) + } + + throw new Error(`${task.method} not supported for Electrum connections`) + } + + function* transactionTask( + txid: string + ): Generator, BlockbookTransaction, unknown> { + // Safe because yielding WsTask should get + // RawTransactionResponse next. + const hex = (yield { + ...rawTransactionMessage(txid) + }) as RawTransactionResponse + const transaction = Transaction.fromHex(hex) + + // Electrum only reports a transaction's height in the history of a + // script it pays. Servers refuse the history of busy scripts, so each + // paid script is tried in turn. When none answers the task fails, so the + // engine retries it instead of taking the transaction as unconfirmed: + let blockHeight = 0 + let historyError: unknown + for (const { script } of transaction.outs) { + if (script[0] === opcodes.OP_RETURN) continue + try { + // Safe because yielding WsTask should get + // AddressTransactionsResponse next. + const history = (yield { + ...addressTransactionsMessage(toElectrumScriptHash(script)) + }) as AddressTransactionsResponse + blockHeight = history.find(item => item.tx_hash === txid)?.height ?? 0 + historyError = undefined + break + } catch (error: unknown) { + log.warn(`failed to fetch the height of transaction ${txid}:`, error) + historyError = error + } + } + if (historyError != null) throw historyError + + // The engine reads a fetched transaction's hex, height, outpoints, and + // outputs. It keeps the date, fee, and input amounts it already stored, + // which Electrum could only supply with a request per previous transaction: + const isCoinbase = transaction.isCoinbase() + return { + txid, + hex, + // Like Blockbook, Electrum puts mempool transactions at height 0 or -1: + blockHeight, + confirmations: 0, + blockTime: 0, + fees: '0', + vin: transaction.ins.map((input, n) => ({ + addresses: [], + n, + sequence: input.sequence, + txid: Buffer.from(input.hash).reverse().toString('hex'), + value: '0', + vout: input.index, + coinbase: isCoinbase ? input.script.toString('hex') : undefined + })), + vout: transaction.outs.map((output, n) => ({ + n, + value: output.value.toString(), + addresses: [], // unused, the engine reads the script in `hex` + hex: output.script.toString('hex') + })) + } } let isWatchingBlocks = false @@ -154,74 +292,24 @@ export function makeBlockbookElectrum( uri: string ): WsTaskAsyncGenerator { const generator = taskGeneratorFn(uri) - let nextValue: unknown + let result = await generator.next() while (true) { - const result = await generator.next(nextValue) - const task = result.value - // If the generator result is not a WsTask we will return it: - if (result?.done === true) return result.value + if (result.done === true) return result.value + const task = result.value if (typeof task === 'boolean') return task - // Translate getAccountUtxo to blockchain.scripthash.listunspent: - if (task.method === 'getAccountUtxo') { - const params = task.params as { descriptor: string } - const address = - asAddress != null ? asAddress(params.descriptor) : params.descriptor - const scriptHash = addressToScriptHash(address) - - // Safe because yielding WsTask should get - // ListUnspentResponse next. - const electrumUtxos = (yield { - ...listUnspentMessage(scriptHash) - }) as ListUnspentResponse - - const blockbookUtxos: BlockbookAccountUtxo[] = electrumUtxos.map( - utxo => ({ - txid: utxo.tx_hash, - vout: utxo.tx_pos, - value: utxo.value.toString(), - height: utxo.height - }) - ) - nextValue = blockbookUtxos - continue - } - - // Get Address Transactions: - if (task.method === 'getAccountInfo') { - const params = task.params as { descriptor: string } - const address = - asAddress != null ? asAddress(params.descriptor) : params.descriptor - const scriptHash = addressToScriptHash(address) - - // Safe because yielding WsTask should get - // AddressTransactionsResponse next. - const electrumUtxos = (yield { - ...addressTransactionsMessage(scriptHash) - }) as AddressTransactionsResponse - - const blockbookUtxos: AddressResponse = { - address, // unused - balance: '0', // unused - totalReceived: '0', // unused - totalSent: '0', // unused - txs: electrumUtxos.length, - unconfirmedBalance: '0', // unused - unconfirmedTxs: electrumUtxos.reduce( - (sum, tx) => (sum + tx.height >= 0 ? 1 : 0), - 0 - ), - txids: [], // unused - transactions: [], // TODO: this requires an extra query per txid - page: 0, // unused - totalPages: 1, - itemsOnPage: 0 // unused - } - nextValue = blockbookUtxos + let response: unknown + try { + response = yield* translateTask(task) + } catch (error: unknown) { + // Hand the failure to the task that made the request, as the socket + // does for the requests it sends unchanged: + result = await generator.throw(error) continue } + result = await generator.next(response) } }, ping: async (): Promise => { @@ -282,6 +370,10 @@ export function makeBlockbookElectrum( return instance } +// Electrum indexes scripts by their SHA-256, in reverse byte order: +const toElectrumScriptHash = (scriptPubkey: Buffer): string => + crypto.sha256(scriptPubkey).reverse().toString('hex') + // A block's hash is its header's double SHA-256, in reverse byte order: const headerToBlockHash = (headerHex: string): string => crypto.hash256(Buffer.from(headerHex, 'hex')).reverse().toString('hex') diff --git a/src/common/utxobased/network/electrumApi.ts b/src/common/utxobased/network/electrumApi.ts index bbd40760..4a2c8473 100644 --- a/src/common/utxobased/network/electrumApi.ts +++ b/src/common/utxobased/network/electrumApi.ts @@ -221,6 +221,19 @@ export const transactionMessage = ( export type TransactionResponse = ElectrumTransaction export const asTransactionResponse = asElectrumTransaction +/** + * Get Raw Transaction + */ +export const rawTransactionMessage = ( + txHash: string +): ElectrumTask => ({ + method: 'blockchain.transaction.get', + params: [txHash, false], + cleaner: asElectrumResponse(asRawTransactionResponse) +}) +export type RawTransactionResponse = ReturnType +export const asRawTransactionResponse = asString + /** * Broadcast Transaction */ diff --git a/test/common/utxobased/network/BlockbookElectrum.spec.ts b/test/common/utxobased/network/BlockbookElectrum.spec.ts index 6c15c2f9..950dbd00 100644 --- a/test/common/utxobased/network/BlockbookElectrum.spec.ts +++ b/test/common/utxobased/network/BlockbookElectrum.spec.ts @@ -8,16 +8,31 @@ import { } from '../../../../src/common/plugin/EngineEmitter' import { info as bitcoin } from '../../../../src/common/utxobased/info/bitcoin' import { Blockbook } from '../../../../src/common/utxobased/network/Blockbook' +import { + BlockbookTransaction, + transactionMessage +} from '../../../../src/common/utxobased/network/blockbookApi' import { makeBlockbookElectrum } from '../../../../src/common/utxobased/network/BlockbookElectrum' import Deferred from '../../../../src/common/utxobased/network/Deferred' import { pingMessage } from '../../../../src/common/utxobased/network/electrumApi' -import { WsTask } from '../../../../src/common/utxobased/network/Socket' +import { + WsTask, + WsTaskAsyncGenerator +} from '../../../../src/common/utxobased/network/Socket' import { SocketEmitter } from '../../../../src/common/utxobased/network/SocketEmitter' import { makeFakeLog } from '../../../utils' const SIGNED_TX = '02000000000101955c9281f8853c0b051e1c1bfe5c90072ca59208676eba7a199191eacbdabd450000000000fdffffff01443217000000000017a914f01eb338a6c45ce9986d0528898c5fd747bb97db8700000000' const TXID = '512ae0bea5252fbcde7783dbbbf6c505c040b7a138645987922489f8d84ac6de' +// The same transaction without a witness, which leaves its txid unchanged: +const RAW_TX = + '0200000001955c9281f8853c0b051e1c1bfe5c90072ca59208676eba7a199191eacbdabd450000000000fdffffff01443217000000000017a914f01eb338a6c45ce9986d0528898c5fd747bb97db87ff64cd1d' +// The same input, paying a P2PKH script ahead of the same P2SH script: +const TWO_OUTPUT_TX = + '0200000001955c9281f8853c0b051e1c1bfe5c90072ca59208676eba7a199191eacbdabd450000000000fdffffff02a0860100000000001976a914000000000000000000000000000000000000000088aca4ab15000000000017a914f01eb338a6c45ce9986d0528898c5fd747bb97db87ff64cd1d' +const TWO_OUTPUT_TXID = + '65a3e1649cb1998375048c9a142f41ac5046eb30a4fc89d9a79fe1501cfdb765' // Bitcoin's genesis block header and hash: const HEADER = '0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c' @@ -43,6 +58,29 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { let requests: ElectrumRequest[] // Builds the JSON-RPC answer body (`result` or `error`) for a request: let answer: (request: ElectrumRequest) => object + // Engine tasks for the socket to pull, each settled with what the task + // receives back: + let queuedTasks: Array<{ + task: WsTask + settled: Deferred + }> + + // The socket pulls tasks after each response, so a ping wakes it: + const runTask = async (task: WsTask): Promise => { + const settled = new Deferred() + queuedTasks.push({ task, settled }) + await blockbook.promisifyWsMessage(pingMessage()) + return await settled.promise + } + + const answerTransaction = ( + history: object + ): ((request: ElectrumRequest) => object) => request => + request.method === 'blockchain.transaction.get' + ? { result: RAW_TX } + : request.method === 'blockchain.scripthash.get_history' + ? history + : { result: null } beforeEach(async () => { requests = [] @@ -70,9 +108,17 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { } }) + queuedTasks = [] async function* taskGeneratorFn( _uri: string - ): AsyncGenerator | boolean> { + ): WsTaskAsyncGenerator { + const queued = queuedTasks.shift() + if (queued == null) return false + try { + queued.settled.resolve(yield queued.task) + } catch (error: unknown) { + queued.settled.reject(error) + } return false } @@ -153,6 +199,112 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { await blockbook.promisifyWsMessage(pingMessage()) }) + it('fetches transactions with blockchain.transaction.get', async () => { + answer = answerTransaction({ result: [{ tx_hash: TXID, height: 974000 }] }) + + const response = (await runTask( + transactionMessage(TXID) + )) as BlockbookTransaction + + expect(response.txid).to.equal(TXID) + expect(response.hex).to.equal(RAW_TX) + expect(response.blockHeight).to.equal(974000) + expect( + response.vin.map(({ n, sequence, txid, vout }) => ({ + n, + sequence, + txid, + vout + })) + ).to.deep.equal([ + { + n: 0, + sequence: 0xfffffffd, + txid: + '45bddacbea9191197aba6e670892a52c07905cfe1b1c1e050b3c85f881925c95', + vout: 0 + } + ]) + expect(response.vout).to.deep.equal([ + { + n: 0, + value: '1520196', + addresses: [], + hex: 'a914f01eb338a6c45ce9986d0528898c5fd747bb97db87' + } + ]) + // The height comes from the history of the script the transaction pays: + const histories = requests.filter( + request => request.method === 'blockchain.scripthash.get_history' + ) + expect(histories.map(request => request.params)).to.deep.equal([ + ['28ff48a12b396e7cdd686b4b51562f5f380e2083b5379b1dc71de9e05f4013e7'] + ]) + }) + + it('reads the height from the next paid script after a refusal', async () => { + let histories = 0 + answer = request => + request.method === 'blockchain.transaction.get' + ? { result: TWO_OUTPUT_TX } + : request.method !== 'blockchain.scripthash.get_history' + ? { result: null } + : histories++ === 0 + ? { error: { code: 1, message: 'history too large' } } + : { result: [{ tx_hash: TWO_OUTPUT_TXID, height: 974000 }] } + + const response = (await runTask( + transactionMessage(TWO_OUTPUT_TXID) + )) as BlockbookTransaction + + expect(response.blockHeight).to.equal(974000) + expect(histories).to.equal(2) + }) + + it('fails the task when no history places the transaction', async () => { + answer = answerTransaction({ + error: { code: 1, message: 'history too large' } + }) + + let error: unknown + try { + await runTask(transactionMessage(TXID)) + } catch (e) { + error = e + } + + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.equal('history too large') + }) + + it('hands failed requests to the task that made them', async () => { + answer = () => ({ error: { code: 2, message: 'missing transaction' } }) + + let error: unknown + try { + await runTask(transactionMessage(TXID)) + } catch (e) { + error = e + } + + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.equal('missing transaction') + }) + + it('rejects tasks it cannot translate', async () => { + let error: unknown + try { + await runTask({ method: 'getTransactionSpecific', params: {} }) + } catch (e) { + error = e + } + + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.equal( + 'getTransactionSpecific not supported for Electrum connections' + ) + }) + it('broadcasts with blockchain.transaction.broadcast', async () => { answer = () => ({ result: TXID }) From 3f80efc4fca896ae37fcc8e01996ddb1cd200403 Mon Sep 17 00:00:00 2001 From: peachbits Date: Thu, 29 Oct 2026 13:24:30 -0700 Subject: [PATCH 06/10] Count Electrum mempool transactions as unconfirmed The count added each transaction's height to the running sum before comparing, so it reached at most one and included confirmed transactions. Electrum puts mempool transactions at height 0 or -1. Only an address's used flag reads the count, and the transaction count already sets that flag, so wallets behave the same. --- .../utxobased/network/BlockbookElectrum.ts | 3 ++- .../network/BlockbookElectrum.spec.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/common/utxobased/network/BlockbookElectrum.ts b/src/common/utxobased/network/BlockbookElectrum.ts index 2e714dae..75f6571a 100644 --- a/src/common/utxobased/network/BlockbookElectrum.ts +++ b/src/common/utxobased/network/BlockbookElectrum.ts @@ -195,7 +195,8 @@ export function makeBlockbookElectrum( txs: electrumUtxos.length, unconfirmedBalance: '0', // unused unconfirmedTxs: electrumUtxos.reduce( - (sum, tx) => (sum + tx.height >= 0 ? 1 : 0), + // Electrum puts mempool transactions at height 0 or -1: + (sum, tx) => sum + (tx.height <= 0 ? 1 : 0), 0 ), txids: [], // unused diff --git a/test/common/utxobased/network/BlockbookElectrum.spec.ts b/test/common/utxobased/network/BlockbookElectrum.spec.ts index 950dbd00..a30405f5 100644 --- a/test/common/utxobased/network/BlockbookElectrum.spec.ts +++ b/test/common/utxobased/network/BlockbookElectrum.spec.ts @@ -9,6 +9,7 @@ import { import { info as bitcoin } from '../../../../src/common/utxobased/info/bitcoin' import { Blockbook } from '../../../../src/common/utxobased/network/Blockbook' import { + AddressResponse, BlockbookTransaction, transactionMessage } from '../../../../src/common/utxobased/network/blockbookApi' @@ -277,6 +278,24 @@ describe('BlockbookElectrum with a dummy Electrum server', function () { expect((error as Error).message).to.equal('history too large') }) + it('counts the mempool transactions in an address history', async () => { + answer = () => ({ + result: [ + { tx_hash: TXID, height: 974000 }, + { tx_hash: TXID, height: 0 }, + { tx_hash: TXID, height: -1 } + ] + }) + + const response = (await runTask({ + method: 'getAccountInfo', + params: { descriptor: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' } + })) as AddressResponse + + expect(response.txs).to.equal(3) + expect(response.unconfirmedTxs).to.equal(2) + }) + it('hands failed requests to the task that made them', async () => { answer = () => ({ error: { code: 2, message: 'missing transaction' } }) From 3ce80aa5ceea36a98e4acd239d43d3c53b0d4d3f Mon Sep 17 00:00:00 2001 From: peachbits Date: Tue, 27 Oct 2026 14:20:55 -0700 Subject: [PATCH 07/10] Keep Electrum servers off address histories Electrum only reports how many transactions an address has, so a wallet that also connects to an Electrum server lost the transactions of every address that server answered for. Address histories now go to a connected Blockbook server whenever there is one. Without one, Electrum still answers, so an Electrum-only wallet can finish syncing. Its answers never move an address's history cursor, and the first Blockbook server to pick a task queries those addresses again, so their transactions arrive once one connects. --- src/common/utxobased/engine/ServerStates.ts | 20 +++- .../utxobased/engine/UtxoEngineProcessor.ts | 71 ++++++++--- .../utxobased/network/BlockbookElectrum.ts | 4 + .../utxobased/engine/ServerStates.spec.ts | 110 ++++++++++++++++++ .../engine/UtxoEngineProcessor.spec.ts | 58 +++++++++ 5 files changed, 243 insertions(+), 20 deletions(-) create mode 100644 test/common/utxobased/engine/UtxoEngineProcessor.spec.ts diff --git a/src/common/utxobased/engine/ServerStates.ts b/src/common/utxobased/engine/ServerStates.ts index 5ad81315..fb33ba57 100644 --- a/src/common/utxobased/engine/ServerStates.ts +++ b/src/common/utxobased/engine/ServerStates.ts @@ -16,7 +16,10 @@ import { BlockbookTransaction, SubscribeAddressResponse } from '../network/blockbookApi' -import { makeBlockbookElectrum } from '../network/BlockbookElectrum' +import { + isElectrumUri, + makeBlockbookElectrum +} from '../network/BlockbookElectrum' import Deferred from '../network/Deferred' import { WsTask, WsTaskGenerator } from '../network/Socket' import { SocketEmitter, SocketEvent } from '../network/SocketEmitter' @@ -73,6 +76,7 @@ export interface ServerStates { stop: () => void serverCanGetTx: (uri: string, txid: string) => boolean serverCanGetAddress: (uri: string, address: string) => boolean + serverCanGetAddressHistory: (uri: string, address: string) => boolean serverIsAwareOfAddress: (uri: string, address: string) => boolean getServerState: (uri: string) => ServerState | undefined refillServers: () => void @@ -510,7 +514,7 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { } // Create a new blockbook instance based on the URI scheme - if (['electrumwss', 'electrumws'].includes(parsed.scheme)) { + if (isElectrumUri(uri)) { // Electrum wrapper blockbook = makeBlockbookElectrum({ asAddress: pluginInfo.engineInfo.asBlockbookAddress, @@ -862,6 +866,18 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { return true }, + serverCanGetAddressHistory(uri: string, address: string): boolean { + // Electrum only reports how many transactions an address has, so the + // history goes to a connected Blockbook server whenever there is one. + // Without one, the counts still let the wallet finish syncing: + const hasBlockbook = Object.entries(serverStatesCache).some( + ([otherUri, state]) => + !isElectrumUri(otherUri) && state.blockbook.isConnected + ) + if (isElectrumUri(uri) && hasBlockbook) return false + return instance.serverCanGetAddress(uri, address) + }, + serverIsAwareOfAddress(uri: string, address: string): boolean { const serverState = serverStatesCache[uri] if (serverState == null) return false diff --git a/src/common/utxobased/engine/UtxoEngineProcessor.ts b/src/common/utxobased/engine/UtxoEngineProcessor.ts index bd7ee3a2..b25eeb14 100644 --- a/src/common/utxobased/engine/UtxoEngineProcessor.ts +++ b/src/common/utxobased/engine/UtxoEngineProcessor.ts @@ -38,6 +38,7 @@ import { SubscribeAddressResponse, TransactionResponse } from '../network/blockbookApi' +import { isElectrumUri } from '../network/BlockbookElectrum' import { WsTask, WsTaskAsyncGenerator } from '../network/Socket' import AwaitLock from './await-lock' import { CACHE_THROTTLE } from './constants' @@ -126,6 +127,11 @@ export function makeUtxoEngineProcessor( pendingTimeouts.clear() } + // Addresses whose history an Electrum server answered. Electrum only + // reports how many transactions an address has, so a Blockbook server + // queries these again once one is picking tasks: + const electrumHistoryAddresses = new Set() + const clearTaskCache = (): void => { for (const key of Object.keys(taskCache.addressForTransactionsCache)) { removeItem(taskCache.addressForTransactionsCache, key) @@ -148,6 +154,7 @@ export function makeUtxoEngineProcessor( for (const key of Object.keys(taskCache.transactionUpdateCache)) { removeItem(taskCache.transactionUpdateCache, key) } + electrumHistoryAddresses.clear() } /** @@ -224,6 +231,28 @@ export function makeUtxoEngineProcessor( } } + /** + * Queues already-processed addresses to be queried again. Each query starts + * from the address's stored lastQueriedBlockHeight, and the address only + * counts as processed once the new queries complete. Returns how many + * addresses were re-queued. + */ + const reopenAddresses = (addresses: Iterable): number => { + let reopened = 0 + for (const address of addresses) { + const cacheItem = taskCache.addressSubscribeCache[address] + if (cacheItem == null || !cacheItem.processing) continue + cacheItem.processing = false + taskCache.addressForUtxosCache[address] = { + processing: false, + path: cacheItem.path + } + progressByScriptPubkey.delete(walletTools.addressToScriptPubkey(address)) + reopened++ + } + return reopened + } + const lock = new AwaitLock() const serverStates = makeServerStates({ @@ -243,6 +272,8 @@ export function makeUtxoEngineProcessor( emitter, taskCache, pendingTimeouts, + electrumHistoryAddresses, + reopenAddresses, updateProgressRatio, updateSeenTxCheckpoint, io, @@ -296,21 +327,9 @@ export function makeUtxoEngineProcessor( // query starts from the address's stored lastQueriedBlockHeight, which is // never above the height of the server that answered it, so the range // the dropped server missed is fetched again. - let requeued = 0 - for (const [address, cacheItem] of Object.entries( - taskCache.addressSubscribeCache - )) { - if (!cacheItem.processing) continue - cacheItem.processing = false - taskCache.addressForUtxosCache[address] = { - processing: false, - path: cacheItem.path - } - // The re-query has to complete again before the address counts as - // processed: - progressByScriptPubkey.delete(walletTools.addressToScriptPubkey(address)) - requeued++ - } + const requeued = reopenAddresses( + Object.keys(taskCache.addressSubscribeCache) + ) log(`${uri} dropped, re-queued ${requeued} addresses`) }) @@ -712,6 +731,8 @@ interface CommonParams { emitter: EngineEmitter taskCache: TaskCache pendingTimeouts: Set + electrumHistoryAddresses: Set + reopenAddresses: (addresses: Iterable) => number updateProgressRatio: ( scriptPubkey: string, process: AddressProcess @@ -926,6 +947,15 @@ export async function* pickNextTask( const serverState = common.serverStates.getServerState(serverUri) if (serverState == null) return false + // Re-query the addresses whose history came from Electrum, now that a + // Blockbook server can answer with their transactions: + const { electrumHistoryAddresses } = common + if (!isElectrumUri(serverUri) && electrumHistoryAddresses.size > 0) { + const reopened = common.reopenAddresses(electrumHistoryAddresses) + electrumHistoryAddresses.clear() + common.log(`${serverUri} re-queued ${reopened} addresses Electrum answered`) + } + // subscribe all servers to new blocks if (serverState.blockSubscriptionStatus === 'unsubscribed') { common.serverStates.watchBlocks(serverUri) @@ -1096,7 +1126,7 @@ export async function* pickNextTask( )) { if ( !cacheItem.processing && - common.serverStates.serverCanGetAddress(serverUri, address) + common.serverStates.serverCanGetAddressHistory(serverUri, address) ) { cacheItem.processing = true @@ -1289,9 +1319,10 @@ async function* processAddressForTransactions( // answering server has indexed, so a lagging server cannot move // lastQueriedBlockHeight past blocks it never saw. A height of 0 means this // server has not reported one yet, so nothing it answers can be trusted to - // move the cursor; the next query covers the range again. + // move the cursor; the next query covers the range again. Electrum answers + // carry no transactions, so they never move it either: const queriedHeight = - serverState.blockHeight > 0 + serverState.blockHeight > 0 && !isElectrumUri(serverUri) ? Math.min(blockHeight, serverState.blockHeight) : addressData.lastQueriedBlockHeight @@ -1386,6 +1417,10 @@ async function* processAddressForTransactions( // Save/update the fully-processed address await common.dataLayer.saveAddress(addressData) + // Electrum saved no transactions, so a Blockbook server queries this + // address again once one is picking tasks: + if (isElectrumUri(serverUri)) common.electrumHistoryAddresses.add(address) + // Extend the lookahead before counting progress, so any addresses it adds // are in the total before this address's flag can bring the ratio to 1: await setLookAhead(common) diff --git a/src/common/utxobased/network/BlockbookElectrum.ts b/src/common/utxobased/network/BlockbookElectrum.ts index 75f6571a..3f5a9b8b 100644 --- a/src/common/utxobased/network/BlockbookElectrum.ts +++ b/src/common/utxobased/network/BlockbookElectrum.ts @@ -371,6 +371,10 @@ export function makeBlockbookElectrum( return instance } +/** True for the URIs of Electrum servers, as opposed to Blockbook servers. */ +export const isElectrumUri = (uri: string): boolean => + /^electrumwss?:/.test(uri) + // Electrum indexes scripts by their SHA-256, in reverse byte order: const toElectrumScriptHash = (scriptPubkey: Buffer): string => crypto.sha256(scriptPubkey).reverse().toString('hex') diff --git a/test/common/utxobased/engine/ServerStates.spec.ts b/test/common/utxobased/engine/ServerStates.spec.ts index 1337a690..2fd9723b 100644 --- a/test/common/utxobased/engine/ServerStates.spec.ts +++ b/test/common/utxobased/engine/ServerStates.spec.ts @@ -537,6 +537,116 @@ describe('ServerStates.broadcastTx', function () { }) }) +describe('ServerStates.serverCanGetAddressHistory', function () { + this.timeout(10000) + + const ADDRESS = 'bc1qtest' + const ELECTRUM_PORT = WS_PORT + 2 + const ELECTRUM_URI = `electrumws://localhost:${ELECTRUM_PORT}` + let blockbookServer: WS.Server + let electrumServer: WS.Server + let serverStates: ServerStates + + const listen = async (server: WS.Server): Promise => + await new Promise(resolve => + server.on('listening', () => { + resolve() + }) + ) + + const connect = async (uri: string): Promise => { + serverStates.setServerList([uri]) + serverStates.refillServers() + await waitFor( + () => serverStates.getServerState(uri)?.blockbook.isConnected === true + ) + } + + beforeEach(async () => { + blockbookServer = new WS.Server({ port: WS_PORT }) + blockbookServer.on('connection', (ws: WebSocket) => { + ws.onmessage = event => { + const data = JSON.parse(event.data) + if (data.method === 'ping') { + ws.send(JSON.stringify({ id: data.id, data: {} })) + } + if (data.method === 'getInfo') { + ws.send( + JSON.stringify({ + id: data.id, + data: { + name: 'Bitcoin', + shortcut: 'BTC', + decimals: 8, + version: '0.0.0', + bestHeight: 1, + bestHash: '00', + block0Hash: '00', + testnet: false + } + }) + ) + } + } + }) + electrumServer = new WS.Server({ port: ELECTRUM_PORT }) + electrumServer.on('connection', (ws: WebSocket) => { + ws.onmessage = event => { + const data = JSON.parse(event.data) + const result = + data.method === 'blockchain.headers.subscribe' + ? { height: 1, hex: '00'.repeat(80) } + : null + ws.send(JSON.stringify({ jsonrpc: '2.0', id: data.id, result })) + } + }) + await Promise.all([listen(blockbookServer), listen(electrumServer)]) + serverStates = makeTestServerStates(makeFakeHttp({}), []) + }) + + afterEach(async () => { + serverStates.stop() + await Promise.all( + [blockbookServer, electrumServer].map( + async server => + await new Promise(resolve => server.close(() => resolve())) + ) + ) + }) + + it('lets Electrum answer address histories when it is the only server', async () => { + await connect(ELECTRUM_URI) + expect( + serverStates.serverCanGetAddressHistory(ELECTRUM_URI, ADDRESS) + ).to.equal(true) + }) + + it('leaves address histories to a connected Blockbook server', async () => { + await connect(ELECTRUM_URI) + await connect(WS_URI) + expect( + serverStates.serverCanGetAddressHistory(ELECTRUM_URI, ADDRESS) + ).to.equal(false) + expect(serverStates.serverCanGetAddressHistory(WS_URI, ADDRESS)).to.equal( + true + ) + // Electrum still serves the address's UTXOs: + expect(serverStates.serverCanGetAddress(ELECTRUM_URI, ADDRESS)).to.equal( + true + ) + }) + + it('hands address histories back to Electrum once Blockbook disconnects', async () => { + await connect(ELECTRUM_URI) + await connect(WS_URI) + for (const client of blockbookServer.clients) client.terminate() + await waitFor(() => serverStates.getServerState(WS_URI) == null) + expect( + serverStates.serverCanGetAddressHistory(ELECTRUM_URI, ADDRESS) + ).to.equal(true) + }) +}) + describe('ServerStates health probe', function () { this.timeout(15000) diff --git a/test/common/utxobased/engine/UtxoEngineProcessor.spec.ts b/test/common/utxobased/engine/UtxoEngineProcessor.spec.ts new file mode 100644 index 00000000..cbab5c14 --- /dev/null +++ b/test/common/utxobased/engine/UtxoEngineProcessor.spec.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai' + +import { pickNextTask } from '../../../../src/common/utxobased/engine/UtxoEngineProcessor' + +type CommonParams = Parameters[0] + +describe('pickNextTask with addresses Electrum answered', function () { + const makeCommon = (): { + common: CommonParams + reopened: string[][] + } => { + const reopened: string[][] = [] + const fakeCommon = { + electrumHistoryAddresses: new Set(['address-1', 'address-2']), + log: () => undefined, + reopenAddresses: (addresses: Iterable): number => { + reopened.push([...addresses]) + return reopened[reopened.length - 1].length + }, + serverStates: { + getServerState: () => ({ blockSubscriptionStatus: 'unsubscribed' }), + watchBlocks: () => undefined + }, + taskCache: { + addressForTransactionsCache: {}, + addressForUtxosCache: {}, + addressSubscribeCache: {}, + blockbookUtxoCache: {}, + dataLayerUtxoCache: {}, + transactionSpecificUpdateCache: {}, + transactionUpdateCache: {} + } + } + // Only the parts pickNextTask reads before it subscribes to blocks: + return { common: (fakeCommon as unknown) as CommonParams, reopened } + } + + it('leaves them alone while an Electrum server picks tasks', async function () { + const { common, reopened } = makeCommon() + + await pickNextTask(common, 'electrumws://localhost:50001').next() + + expect(reopened).to.deep.equal([]) + expect([...common.electrumHistoryAddresses]).to.deep.equal([ + 'address-1', + 'address-2' + ]) + }) + + it('re-queues them once a Blockbook server picks a task', async function () { + const { common, reopened } = makeCommon() + + await pickNextTask(common, 'wss://blockbook.example.com/websocket').next() + + expect(reopened).to.deep.equal([['address-1', 'address-2']]) + expect(common.electrumHistoryAddresses.size).to.equal(0) + }) +}) From b4b229fb03986b1b8e53adf7d6db8dcdb867340d Mon Sep 17 00:00:00 2001 From: peachbits Date: Fri, 23 Oct 2026 12:59:24 -0700 Subject: [PATCH 08/10] Support a per-coin replay-protection nLockTime CoinInfo.lockTime sets nLockTime on every transaction for forks that use a magic nLockTime as opt-in replay protection. Without RBF, inputs use sequence 0xfffffffe so nodes still enforce the nLockTime. --- src/common/plugin/types.ts | 11 +++++++++++ src/common/utxobased/keymanager/keymanager.ts | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/common/plugin/types.ts b/src/common/plugin/types.ts index e5081050..4db046b3 100644 --- a/src/common/plugin/types.ts +++ b/src/common/plugin/types.ts @@ -210,6 +210,17 @@ export interface CoinInfo { */ sighash?: number + /** + * Optional nLockTime value set on every transaction. This is used by forks + * whose nodes treat a magic nLockTime as final while the parent chain reads + * it as an unreachable block height, making the fork's transactions + * impossible to replay on the parent chain. + * + * Transactions for these currencies always have a non-final input sequence, + * since nodes ignore nLockTime when every input sequence is final. + */ + lockTime?: number + /** * A function to be passed to AltcoinJS `signInput` method. This is used to * get the input hash for the signature algorithm before signing the input. diff --git a/src/common/utxobased/keymanager/keymanager.ts b/src/common/utxobased/keymanager/keymanager.ts index b6912636..94ba4aa8 100644 --- a/src/common/utxobased/keymanager/keymanager.ts +++ b/src/common/utxobased/keymanager/keymanager.ts @@ -1083,14 +1083,18 @@ export function signMessageBase64( export function makeTx(args: MakeTxArgs): MakeTxReturn { const { log, outputSort, memos, memoIndex } = args + const coin = getCoinFromString(args.coin) + let sequence = 0xffffffff if (args.enableRbf) { sequence -= 2 + } else if (coin.lockTime != null) { + // Nodes ignore nLockTime unless an input sequence is non-final: + sequence -= 1 } // get coin specific replay protection sighhash bits let sighashType = Transaction.SIGHASH_ALL - const coin = getCoinFromString(args.coin) if (coin.sighash != null) { sighashType = coin.sighash } @@ -1246,6 +1250,7 @@ export function makeTx(args: MakeTxArgs): MakeTxReturn { } const psbt = new Psbt() + if (coin.lockTime != null) psbt.setLocktime(coin.lockTime) try { psbt.addInputs(sortedInputs) psbt.addOutputs(sortedOutputs) From 617c57cc01bcc660b09070ba28a11817754228fa Mon Sep 17 00:00:00 2001 From: peachbits Date: Fri, 23 Oct 2026 13:02:23 -0700 Subject: [PATCH 09/10] Add eCash (ECX) mainnet and betanet plugins ECX is a Bitcoin hard fork with Bitcoin's address and key formats. Both plugins set the network's magic nLockTime on every transaction, so ECX spends cannot be replayed on Bitcoin. Both default to eCash's Blockbook servers, and take fee rates and explorer links from eCash's mempool.space-style explorers. --- src/common/utxobased/info/all.ts | 6 ++ src/common/utxobased/info/ecashbitcoin.ts | 90 ++++++++++++++++ .../utxobased/info/ecashbitcoinbetanet.ts | 91 ++++++++++++++++ .../coins/ecashbitcointransactiontest.spec.ts | 100 ++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 src/common/utxobased/info/ecashbitcoin.ts create mode 100644 src/common/utxobased/info/ecashbitcoinbetanet.ts create mode 100644 test/common/utxobased/keymanager/coins/ecashbitcointransactiontest.spec.ts diff --git a/src/common/utxobased/info/all.ts b/src/common/utxobased/info/all.ts index 67837cfd..a84fcdf8 100644 --- a/src/common/utxobased/info/all.ts +++ b/src/common/utxobased/info/all.ts @@ -12,6 +12,8 @@ import { info as digibyte } from './digibyte' import { info as dogecoin } from './dogecoin' import { info as eboost } from './eboost' import { info as ecash } from './ecash' +import { info as ecashbitcoin } from './ecashbitcoin' +import { info as ecashbitcoinbetanet } from './ecashbitcoinbetanet' import { info as feathercoin } from './feathercoin' import { info as groestlcoin } from './groestlcoin' import { info as litecoin } from './litecoin' @@ -37,6 +39,8 @@ export { info as digibyte } from './digibyte' export { info as dogecoin } from './dogecoin' export { info as eboost } from './eboost' export { info as ecash } from './ecash' +export { info as ecashbitcoin } from './ecashbitcoin' +export { info as ecashbitcoinbetanet } from './ecashbitcoinbetanet' export { info as feathercoin } from './feathercoin' export { info as groestlcoin } from './groestlcoin' export { info as litecoin } from './litecoin' @@ -63,6 +67,8 @@ export const all = [ dogecoin, eboost, ecash, + ecashbitcoin, + ecashbitcoinbetanet, feathercoin, groestlcoin, litecoin, diff --git a/src/common/utxobased/info/ecashbitcoin.ts b/src/common/utxobased/info/ecashbitcoin.ts new file mode 100644 index 00000000..409ee9b5 --- /dev/null +++ b/src/common/utxobased/info/ecashbitcoin.ts @@ -0,0 +1,90 @@ +import { EdgeCurrencyInfo } from 'edge-core-js/types' + +import { CoinInfo, EngineInfo, PluginInfo } from '../../plugin/types' +import { maximumFeeRateCalculator } from '../../plugin/util/maximumFeeRateCalculator' +import { + legacyMemoInfo, + utxoCustomFeeTemplate, + utxoMemoOptions +} from './commonInfo' + +const currencyInfo: EdgeCurrencyInfo = { + assetDisplayName: 'eCash', + canReplaceByFee: true, + chainDisplayName: 'eCash', + currencyCode: 'ECX', + customFeeTemplate: utxoCustomFeeTemplate, + memoOptions: utxoMemoOptions, + pluginId: 'ecashbitcoin', + walletType: 'wallet:ecashbitcoin', + + // Explorers: + blockExplorer: 'https://explorer.ecash.ninja/block/%s', + addressExplorer: 'https://explorer.ecash.ninja/address/%s', + transactionExplorer: 'https://explorer.ecash.ninja/tx/%s', + + denominations: [ + { name: 'ECX', multiplier: '100000000' }, + { name: 'sats', multiplier: '1', symbol: 's' } + ], + + // Deprecated: + ...legacyMemoInfo, + defaultSettings: { + customFeeSettings: ['satPerByte'], + blockbookServers: ['wss://blockbook.ecash.ninja/websocket'], + enableCustomServers: false + }, + displayName: 'eCash', + metaTokens: [] +} + +const engineInfo: EngineInfo = { + formats: ['bip49', 'bip84', 'bip44', 'bip32'], + // Matches Bitcoin, so wallets split from Bitcoin find every pre-fork address: + gapLimit: 25, + feeUpdateInterval: 60000, + mempoolSpaceFeeInfoServer: 'https://explorer.ecash.ninja/api/v1/fees/precise', + defaultFeeInfo: { + lowFeeFudgeFactor: undefined, + standardFeeLowFudgeFactor: undefined, + standardFeeHighFudgeFactor: undefined, + highFeeFudgeFactor: undefined, + + highFee: '10', + lowFee: '1', + standardFeeLow: '2', + standardFeeHigh: '5', + standardFeeLowAmount: '173200', + standardFeeHighAmount: '8670000', + maximumFeeRate: maximumFeeRateCalculator(currencyInfo, 100) + } +} + +export const coinInfo: CoinInfo = { + name: 'ecashbitcoin', + segwit: true, + coinType: 0, + // LOCKTIME_THRESHOLD - 2, final only on eCash mainnet: + lockTime: 499999998, + + prefixes: { + messagePrefix: ['\x18Bitcoin Signed Message:\n'], + wif: [0x80], + legacyXPriv: [0x0488ade4], + legacyXPub: [0x0488b21e], + wrappedSegwitXPriv: [0x049d7878], + wrappedSegwitXPub: [0x049d7cb2], + segwitXPriv: [0x04b2430c], + segwitXPub: [0x04b24746], + pubkeyHash: [0x00], + scriptHash: [0x05], + bech32: ['bc'] + } +} + +export const info: PluginInfo = { + currencyInfo, + engineInfo, + coinInfo +} diff --git a/src/common/utxobased/info/ecashbitcoinbetanet.ts b/src/common/utxobased/info/ecashbitcoinbetanet.ts new file mode 100644 index 00000000..a56898ff --- /dev/null +++ b/src/common/utxobased/info/ecashbitcoinbetanet.ts @@ -0,0 +1,91 @@ +import { EdgeCurrencyInfo } from 'edge-core-js/types' + +import { CoinInfo, EngineInfo, PluginInfo } from '../../plugin/types' +import { maximumFeeRateCalculator } from '../../plugin/util/maximumFeeRateCalculator' +import { + legacyMemoInfo, + utxoCustomFeeTemplate, + utxoMemoOptions +} from './commonInfo' + +const currencyInfo: EdgeCurrencyInfo = { + assetDisplayName: 'eCash Betanet', + canReplaceByFee: true, + chainDisplayName: 'eCash Betanet', + currencyCode: 'BETAECX', + customFeeTemplate: utxoCustomFeeTemplate, + memoOptions: utxoMemoOptions, + pluginId: 'ecashbitcoinbetanet', + walletType: 'wallet:ecashbitcoinbetanet', + + // Explorers: + blockExplorer: 'https://explorer.beta.ecash.ninja/block/%s', + addressExplorer: 'https://explorer.beta.ecash.ninja/address/%s', + transactionExplorer: 'https://explorer.beta.ecash.ninja/tx/%s', + + denominations: [ + { name: 'BETAECX', multiplier: '100000000' }, + { name: 'sats', multiplier: '1', symbol: 's' } + ], + + // Deprecated: + ...legacyMemoInfo, + defaultSettings: { + customFeeSettings: ['satPerByte'], + blockbookServers: ['wss://blockbook.beta.ecash.ninja/websocket'], + enableCustomServers: false + }, + displayName: 'eCash Betanet', + metaTokens: [] +} + +const engineInfo: EngineInfo = { + formats: ['bip49', 'bip84', 'bip44', 'bip32'], + // Matches Bitcoin, so wallets split from Bitcoin find every pre-fork address: + gapLimit: 25, + feeUpdateInterval: 60000, + mempoolSpaceFeeInfoServer: + 'https://explorer.beta.ecash.ninja/api/v1/fees/precise', + defaultFeeInfo: { + lowFeeFudgeFactor: undefined, + standardFeeLowFudgeFactor: undefined, + standardFeeHighFudgeFactor: undefined, + highFeeFudgeFactor: undefined, + + highFee: '10', + lowFee: '1', + standardFeeLow: '2', + standardFeeHigh: '5', + standardFeeLowAmount: '173200', + standardFeeHighAmount: '8670000', + maximumFeeRate: maximumFeeRateCalculator(currencyInfo, 100) + } +} + +export const coinInfo: CoinInfo = { + name: 'ecashbitcoinbetanet', + segwit: true, + coinType: 0, + // LOCKTIME_THRESHOLD - 1, final only on eCash betanet: + lockTime: 499999999, + + prefixes: { + messagePrefix: ['\x18Bitcoin Signed Message:\n'], + wif: [0x80], + legacyXPriv: [0x0488ade4], + legacyXPub: [0x0488b21e], + wrappedSegwitXPriv: [0x049d7878], + wrappedSegwitXPub: [0x049d7cb2], + segwitXPriv: [0x04b2430c], + segwitXPub: [0x04b24746], + pubkeyHash: [0x00], + scriptHash: [0x05], + bech32: ['bc'] + } +} + +export const info: PluginInfo = { + currencyInfo, + engineInfo, + coinInfo +} diff --git a/test/common/utxobased/keymanager/coins/ecashbitcointransactiontest.spec.ts b/test/common/utxobased/keymanager/coins/ecashbitcointransactiontest.spec.ts new file mode 100644 index 00000000..22d3f95f --- /dev/null +++ b/test/common/utxobased/keymanager/coins/ecashbitcointransactiontest.spec.ts @@ -0,0 +1,100 @@ +import { Psbt, Transaction } from 'altcoin-js' +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { info as ecashbitcoin } from '../../../../../src/common/utxobased/info/ecashbitcoin' +import { + makeTx, + privateKeyEncodingToPubkey, + pubkeyToScriptPubkey, + ScriptTypeEnum, + signTx, + wifToPrivateKeyEncoding +} from '../../../../../src/common/utxobased/keymanager/keymanager' + +describe('eCash (ECX) replay-protection nLockTime', function () { + this.timeout(10000) + + const wifKey = 'L2uPYXe17xSTqbCjZvL2DsyXPCbXspvcu5mHLDYUgzdUbZGSKrSr' + const privateKeyEncoding = wifToPrivateKeyEncoding({ + wifKey, + coin: 'bitcoin' + }) + const { scriptPubkey } = pubkeyToScriptPubkey({ + pubkey: privateKeyEncodingToPubkey(privateKeyEncoding), + scriptType: ScriptTypeEnum.p2pkh + }) + + const makeTestTx = (coin: string, enableRbf: boolean): Psbt => { + const { psbtBase64 } = makeTx({ + forceUseUtxo: [], + coin, + currencyCode: 'ECX', + enableRbf, + freshChangeAddress: '1KRMKfeZcmosxALVYESdPNez1AP1mEtywp', + feeRate: 0, + subtractFee: false, + utxos: [ + { + id: '0', + scriptType: ScriptTypeEnum.p2pkh, + txid: + '7d067b4a697a09d2c3cff7d4d9506c9955e93bff41bf82d439da7d030382bc3e', + scriptPubkey, + value: '80000', + blockHeight: 0, + spent: false, + script: + '0200000001f9f34e95b9d5c8abcd20fc5bd4a825d1517be62f0f775e5f36da944d9' + + '452e550000000006b483045022100c86e9a111afc90f64b4904bd609e9eaed80d48' + + 'ca17c162b1aca0a788ac3526f002207bb79b60d4fc6526329bf18a77135dc566020' + + '9e761da46e1c2f1152ec013215801210211755115eabf846720f5cb18f248666fec' + + '631e5e1e66009ce3710ceea5b1ad13ffffffff01' + + '905f010000000000' + + '19' + + scriptPubkey + + '00000000', + vout: 0 + } + ], + targets: [], + memos: [], + outputSort: 'bip69' + }) + return Psbt.fromBase64(psbtBase64) + } + + it('sets the mainnet nLockTime with a non-final sequence', () => { + const psbt = makeTestTx('ecashbitcoin', false) + expect(psbt.locktime).to.equal(499999998) + expect(psbt.txInputs[0].sequence).to.equal(0xfffffffe) + }) + + it('keeps the RBF sequence when RBF is enabled', () => { + const psbt = makeTestTx('ecashbitcoin', true) + expect(psbt.locktime).to.equal(499999998) + expect(psbt.txInputs[0].sequence).to.equal(0xfffffffd) + }) + + it('sets the betanet nLockTime', () => { + const psbt = makeTestTx('ecashbitcoinbetanet', false) + expect(psbt.locktime).to.equal(499999999) + }) + + it('leaves Bitcoin transactions without an nLockTime', () => { + const psbt = makeTestTx('bitcoin', false) + expect(psbt.locktime).to.equal(0) + expect(psbt.txInputs[0].sequence).to.equal(0xffffffff) + }) + + it('keeps the nLockTime after signing', async () => { + const psbt = makeTestTx('ecashbitcoin', false) + const signedTx = await signTx({ + coin: 'ecashbitcoin', + feeInfo: ecashbitcoin.engineInfo.defaultFeeInfo, + privateKeyEncodings: [privateKeyEncoding], + psbtBase64: psbt.toBase64() + }) + expect(Transaction.fromHex(signedTx.hex).locktime).to.equal(499999998) + }) +}) From 0dff20b9360b03f0d1866bd28f8c8998ef8180a5 Mon Sep 17 00:00:00 2001 From: peachbits Date: Fri, 23 Oct 2026 13:03:35 -0700 Subject: [PATCH 10/10] Allow splitting Bitcoin wallets into eCash (ECX) Every Bitcoin wallet format can split into the ECX mainnet and betanet wallet types. edge-core-js sweeps the new ECX wallet to itself after the split, which moves its coins off the UTXOs it shares with Bitcoin. --- CHANGELOG.md | 10 +++++++ src/common/utxobased/info/bitcoin.ts | 2 +- .../currencies/bitcoin.ts | 26 ++++++++++++++++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 787fd56d..49547eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- added: Broadcast transactions through Electrum (`electrumws(s):`) servers with `blockchain.transaction.broadcast`, alongside the Blockbook servers that already receive them. +- added: Track the block height through Electrum servers with `blockchain.headers.subscribe`, and confirm unconfirmed transactions on each new block from the history of a script they pay. +- added: Fetch transactions through Electrum servers with `blockchain.transaction.get`, so legacy (bip44 and bip32) wallets can sync their UTXOs. +- added: eCash (ECX) mainnet and betanet plugins, which set the network's magic nLockTime on every transaction as replay protection against Bitcoin. +- added: Bitcoin wallets can be split into eCash (ECX) wallets. +- fixed: Electrum connections closing after every `server.ping`, because Fulcrum rejects the object the params were sent as. +- fixed: Electrum request failures stalling the wallet sync. A failed request now reaches the task that made it, which retries, and a task Electrum cannot answer fails instead of receiving the previous task's response. +- fixed: Missing transaction history in wallets that also connect to an Electrum server, which only reports how many transactions an address has. Address histories now come from a connected Blockbook server whenever there is one. +- fixed: Requests a sync task made after a failed or timed-out request never being sent, which left the rest of that task's work, such as other transactions' confirmation checks, undone until the next block. + ## 3.16.0 (2026-10-09) - changed: Share each server's health probe between every wallet of the same currency. Probes of one server less than ten seconds apart now share a single REST request, so N wallets connected to the same Edge Blockbook cost one probe per keepalive interval rather than N. diff --git a/src/common/utxobased/info/bitcoin.ts b/src/common/utxobased/info/bitcoin.ts index 610cdf29..fbcfc0ee 100644 --- a/src/common/utxobased/info/bitcoin.ts +++ b/src/common/utxobased/info/bitcoin.ts @@ -62,7 +62,7 @@ const engineInfo: EngineInfo = { } ], formats: ['bip49', 'bip84', 'bip44', 'bip32'], - forks: ['bitcoincash', 'bitcoingold'], + forks: ['bitcoincash', 'bitcoingold', 'ecashbitcoin', 'ecashbitcoinbetanet'], gapLimit: 25, feeUpdateInterval: 60000, mempoolSpaceFeeInfoServer: 'https://mempool.space/api/v1/fees/precise', diff --git a/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoin.ts b/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoin.ts index 58efefd7..6c5ed4fd 100644 --- a/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoin.ts +++ b/test/common/plugin/CurrencyPlugin.fixtures/currencies/bitcoin.ts @@ -297,9 +297,27 @@ export const bitcoin: FixtureType = { ] }, getSplittableTypes: { - bip32: ['wallet:bitcoincash', 'wallet:bitcoingold'], - bip44: ['wallet:bitcoincash', 'wallet:bitcoingold'], - bip49: ['wallet:bitcoingold'], - bip84: ['wallet:bitcoingold'] + bip32: [ + 'wallet:bitcoincash', + 'wallet:bitcoingold', + 'wallet:ecashbitcoin', + 'wallet:ecashbitcoinbetanet' + ], + bip44: [ + 'wallet:bitcoincash', + 'wallet:bitcoingold', + 'wallet:ecashbitcoin', + 'wallet:ecashbitcoinbetanet' + ], + bip49: [ + 'wallet:bitcoingold', + 'wallet:ecashbitcoin', + 'wallet:ecashbitcoinbetanet' + ], + bip84: [ + 'wallet:bitcoingold', + 'wallet:ecashbitcoin', + 'wallet:ecashbitcoinbetanet' + ] } }