diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d3fbe4..2f249d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- changed: `broadcastTx` now sends to every cached Blockbook socket and every HTTP fallback server at once, resolving on the first success and rejecting only when all attempts fail. Previously the HTTP fallback ran only when no socket reported itself connected, so one unresponsive socket could fail the whole broadcast without any HTTP attempt. +- fixed: Stop sending the NOWNodes API key to Edge's own Blockbook HTTP servers. They are now configured as plain `blockbook` servers, which are also used when no NOWNodes key is configured. + ## 3.12.0 (2026-08-05) - added: `signatureFormat` option on `signMessage`, accepting `electrum` (the default) or `bip137`. Existing callers keep the legacy Electrum header byte; BIP137 is opt-in. diff --git a/src/common/plugin/types.ts b/src/common/plugin/types.ts index dbc6f3bf..e5081050 100644 --- a/src/common/plugin/types.ts +++ b/src/common/plugin/types.ts @@ -158,7 +158,13 @@ export interface EngineInfo { } export interface ServerConfig { - type: 'blockbook-nownode' + /** + * - `blockbook`: a public Blockbook HTTP server. No credentials are sent. + * - `blockbook-nownode`: a NOWNodes Blockbook HTTP server. Requests carry + * the `nowNodesApiKey` init option as the `api-key` header, and the + * server is skipped when no key is configured. + */ + type: 'blockbook' | 'blockbook-nownode' uris: string[] } diff --git a/src/common/utxobased/engine/ServerStates.ts b/src/common/utxobased/engine/ServerStates.ts index 18d289ab..27f624e3 100644 --- a/src/common/utxobased/engine/ServerStates.ts +++ b/src/common/utxobased/engine/ServerStates.ts @@ -366,112 +366,127 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { } const instance: ServerStates = { + /** + * Broadcast to every server we can reach, all at once: + * + * - Every blockbook in the connection cache, whether or not its socket + * has finished connecting (a queued request transmits as soon as the + * socket opens, and times out otherwise). + * - Every HTTP fallback server from `serverConfigs`, regardless of the + * WebSocket state. + * + * The first success wins. The promise rejects only after every attempt + * has failed. Earlier versions only used the HTTP fallback when no socket + * was connected, so a single socket that looked connected but never + * answered could fail the whole broadcast without any HTTP attempt. + */ async broadcastTx(transaction: EdgeTransaction): Promise { return await new Promise((resolve, reject) => { let resolved = false - let bad = 0 - - const wsUris = Object.keys(serverStatesCache).filter( - uri => serverStatesCache[uri].blockbook != null - ) - - // Determine if there are any connected blockbook instances - const isAnyBlockbookConnected = wsUris.some( - uri => serverStatesCache[uri].blockbook.isConnected - ) - - if (isAnyBlockbookConnected) { - for (const uri of wsUris) { - const { blockbook } = serverStatesCache[uri] - if (blockbook == null) continue - blockbook - .broadcastTx(transaction) - .then(response => { - if (!resolved) { - resolved = true - resolve(response.result) - } - }) - .catch((e?: Error) => { - if (++bad === wsUris.length) { - const msg = e != null ? `With error ${e.message}` : '' - log.error( - `broadcastTx fail: ${JSON.stringify(transaction)}\n${msg}` - ) - reject(e) - } - }) - } + let attempts = 0 + let failures = 0 + const failureMessages: string[] = [] + + const onSuccess = (uri: string, txid: string): void => { + if (resolved) return + resolved = true + log(`broadcastTx succeeded via ${uri}: ${txid}`) + resolve(txid) + } + const onFailure = (uri: string, error: unknown): void => { + const message = error instanceof Error ? error.message : String(error) + failureMessages.push(`${uri}: ${message}`) + log.warn(`broadcastTx attempt failed for ${uri}: ${message}`) + if (++failures < attempts || resolved) return + log.error( + `broadcastTx fail: ${JSON.stringify( + transaction + )}\n${failureMessages.join('\n')}` + ) + reject( + error instanceof Error + ? error + : new Error(`Broadcast failed: ${failureMessages.join('; ')}`) + ) } - // Broadcast through any HTTP URI that may be configured, only if no - // blockbook instances are connected. - if (!isAnyBlockbookConnected) { - // This is for the future when we want to get HTTP servers from the user - // settings: - // const httpUris = pluginState.getLocalServers(Infinity, [ - // /^http(?:s)?:/i - // ]) - - const { nowNodesApiKey } = initOptions - const nowNodeUris = serverConfigs - .filter(config => config.type === 'blockbook-nownode') - .map(config => config.uris) - .flat(1) - - // If there are no HTTP servers, reject the promise - if (nowNodeUris.length < 1) { - // If no HTTP servers are available, and we had no connected blockbook - // instances, reject the promise with a message indicating no - // available connections. - reject( - new Error('No available connections. Check your internet signal.') - ) - return - } + // + // WebSocket blockbook servers (every cached connection): + // + for (const uri of Object.keys(serverStatesCache)) { + const { blockbook } = serverStatesCache[uri] + if (blockbook == null) continue + attempts++ + blockbook + .broadcastTx(transaction) + .then(response => { + onSuccess(uri, response.result) + }) + .catch((error: unknown) => { + onFailure(uri, error) + }) + } - // If there is no key for the NowNode servers: - if (nowNodesApiKey == null) { - reject(new Error('Missing connection key for fallback servers.')) - return + // + // HTTP servers (always attempted, in parallel with the sockets): + // + // This is for the future when we want to get HTTP servers from the user + // settings: + // const httpUris = pluginState.getLocalServers(Infinity, [ + // /^http(?:s)?:/i + // ]) + const { nowNodesApiKey } = initOptions + const httpTargets: Array<{ + uri: string + headers: { [key: string]: string } + }> = [] + for (const config of serverConfigs) { + if (config.type === 'blockbook-nownode') { + // NOWNodes requires the key, and the key must not go anywhere else: + if (nowNodesApiKey == null) { + log.warn( + 'broadcastTx: skipping NOWNodes HTTP servers (no nowNodesApiKey)' + ) + continue + } + for (const uri of config.uris) { + httpTargets.push({ uri, headers: { 'api-key': nowNodesApiKey } }) + } + } else { + for (const uri of config.uris) { + httpTargets.push({ uri, headers: {} }) + } } + } - for (const uri of nowNodeUris) { - log.warn('Falling back to NOWNode server broadcast over HTTP:', uri) - - // HTTP Fallback - io.fetchCors(`${uri}/api/v2/sendtx/`, { - method: 'POST', - headers: { - 'api-key': nowNodesApiKey - }, - body: transaction.signedTx + for (const { uri, headers } of httpTargets) { + attempts++ + io.fetchCors(`${uri}/api/v2/sendtx/`, { + method: 'POST', + headers, + body: transaction.signedTx + }) + .then(async response => { + if (!response.ok) { + throw new Error( + `Failed to broadcast transaction via Blockbook: HTTP ${response.status}` + ) + } + const json = await response.json() + return asBlockbookResponse(asBroadcastTxResponse)(json) }) - .then(async response => { - if (!response.ok) { - throw new Error( - `Failed to broadcast transaction via Blockbook: HTTP ${response.status}` - ) - } - const json = await response.json() - return asBlockbookResponse(asBroadcastTxResponse)(json) - }) - .then(response => { - if (!resolved) { - resolved = true - resolve(response.result) - } - }) - .catch((e?: Error) => { - if (++bad === nowNodeUris.length) { - const msg = e != null ? `With error ${e.message}` : '' - log.error( - `broadcastTx fail: ${JSON.stringify(transaction)}\n${msg}` - ) - reject(e) - } - }) - } + .then(response => { + onSuccess(uri, response.result) + }) + .catch((error: unknown) => { + onFailure(uri, error) + }) + } + + if (attempts === 0) { + reject( + new Error('No available connections. Check your internet signal.') + ) } }) }, diff --git a/src/common/utxobased/info/bitcoin.ts b/src/common/utxobased/info/bitcoin.ts index abc5cde0..a7f0a101 100644 --- a/src/common/utxobased/info/bitcoin.ts +++ b/src/common/utxobased/info/bitcoin.ts @@ -53,7 +53,7 @@ const currencyInfo: EdgeCurrencyInfo = { const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://btc-wusa1.edge.app', 'https://btc-eu1.edge.app'] }, { diff --git a/src/common/utxobased/info/bitcoincash.ts b/src/common/utxobased/info/bitcoincash.ts index 339e88d0..4add1f96 100644 --- a/src/common/utxobased/info/bitcoincash.ts +++ b/src/common/utxobased/info/bitcoincash.ts @@ -53,7 +53,7 @@ const currencyInfo: EdgeCurrencyInfo = { const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://bch-eusa1.edge.app'] }, { diff --git a/src/common/utxobased/info/dash.ts b/src/common/utxobased/info/dash.ts index ada0c44c..2769a52c 100644 --- a/src/common/utxobased/info/dash.ts +++ b/src/common/utxobased/info/dash.ts @@ -52,7 +52,7 @@ const currencyInfo: EdgeCurrencyInfo = { const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://dash-wusa1.edge.app'] }, { diff --git a/src/common/utxobased/info/digibyte.ts b/src/common/utxobased/info/digibyte.ts index bd0ebc3d..3b7e0516 100644 --- a/src/common/utxobased/info/digibyte.ts +++ b/src/common/utxobased/info/digibyte.ts @@ -46,7 +46,7 @@ const currencyInfo: EdgeCurrencyInfo = { const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://dgb-eu1.edge.app'] }, { diff --git a/src/common/utxobased/info/dogecoin.ts b/src/common/utxobased/info/dogecoin.ts index 5764aa0d..279c31b3 100644 --- a/src/common/utxobased/info/dogecoin.ts +++ b/src/common/utxobased/info/dogecoin.ts @@ -48,7 +48,7 @@ const currencyInfo: EdgeCurrencyInfo = { const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://doge-eusa1.edge.app'] }, { diff --git a/src/common/utxobased/info/litecoin.ts b/src/common/utxobased/info/litecoin.ts index f23a1719..2cdaa9a3 100644 --- a/src/common/utxobased/info/litecoin.ts +++ b/src/common/utxobased/info/litecoin.ts @@ -50,7 +50,7 @@ export const currencyInfo: EdgeCurrencyInfo = { export const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://ltc-wusa1.edge.app'] }, { diff --git a/src/common/utxobased/info/pivx.ts b/src/common/utxobased/info/pivx.ts index 245b28c5..a0b4b208 100644 --- a/src/common/utxobased/info/pivx.ts +++ b/src/common/utxobased/info/pivx.ts @@ -44,7 +44,7 @@ const currencyInfo: EdgeCurrencyInfo = { const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://pivx-wusa1.edge.app'] }, { diff --git a/src/common/utxobased/info/qtum.ts b/src/common/utxobased/info/qtum.ts index ad27cdbe..79f2b47c 100644 --- a/src/common/utxobased/info/qtum.ts +++ b/src/common/utxobased/info/qtum.ts @@ -42,7 +42,7 @@ const currencyInfo: EdgeCurrencyInfo = { const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://qtum-wusa1.edge.app'] } ], diff --git a/src/common/utxobased/info/vertcoin.ts b/src/common/utxobased/info/vertcoin.ts index 1884cac2..21301ddb 100644 --- a/src/common/utxobased/info/vertcoin.ts +++ b/src/common/utxobased/info/vertcoin.ts @@ -48,7 +48,7 @@ const currencyInfo: EdgeCurrencyInfo = { const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://vtc-wusa1.edge.app'] } ], diff --git a/src/common/utxobased/info/zcoin.ts b/src/common/utxobased/info/zcoin.ts index 1e111c02..584324d5 100644 --- a/src/common/utxobased/info/zcoin.ts +++ b/src/common/utxobased/info/zcoin.ts @@ -45,7 +45,7 @@ export const currencyInfo: EdgeCurrencyInfo = { export const engineInfo: EngineInfo = { serverConfigs: [ { - type: 'blockbook-nownode', + type: 'blockbook', uris: ['https://firo-eusa1.edge.app'] }, { diff --git a/test/common/utxobased/engine/ServerStates.spec.ts b/test/common/utxobased/engine/ServerStates.spec.ts new file mode 100644 index 00000000..46034b7d --- /dev/null +++ b/test/common/utxobased/engine/ServerStates.spec.ts @@ -0,0 +1,330 @@ +import { expect } from 'chai' +import { makeFakeIo } from 'edge-core-js' +import { + EdgeFetchOptions, + EdgeFetchResponse, + EdgeTransaction +} from 'edge-core-js/types' +import WS from 'ws' + +import { EngineEmitter } from '../../../../src/common/plugin/EngineEmitter' +import { PluginState } from '../../../../src/common/plugin/PluginState' +import { ServerConfig } from '../../../../src/common/plugin/types' +import { + makeServerStates, + ServerStates +} from '../../../../src/common/utxobased/engine/ServerStates' +import { SafeWalletInfo } from '../../../../src/common/utxobased/keymanager/cleaners' +import { makeFakeLog, makeFakePluginInfo } from '../../../utils' + +const TXID = 'deadbeef' +const WS_PORT = 8556 +const WS_URI = `ws://localhost:${WS_PORT}` + +type HttpBehavior = 'ok' | 'fail' + +interface FakeHttp { + calls: string[] + headers: { [uri: string]: { [key: string]: string } } + fetchCors: ( + uri: string, + opts?: EdgeFetchOptions + ) => Promise +} + +const makeFakeHttp = (behaviors: { [uri: string]: HttpBehavior }): FakeHttp => { + const calls: string[] = [] + const headers: FakeHttp['headers'] = {} + return { + calls, + headers, + async fetchCors( + uri: string, + opts?: EdgeFetchOptions + ): Promise { + calls.push(uri) + headers[uri] = { ...(opts?.headers ?? {}) } + const base = Object.keys(behaviors).find(key => uri.startsWith(key)) + const behavior = base != null ? behaviors[base] : 'fail' + if (behavior === 'ok') { + return ({ + ok: true, + status: 200, + json: async () => ({ result: TXID }) + } as unknown) as EdgeFetchResponse + } + return ({ + ok: false, + status: 500, + json: async () => ({}) + } as unknown) as EdgeFetchResponse + } + } +} + +const fakePluginState = ({ + serverScoreUp: () => {}, + serverScoreDown: () => {}, + getLocalServers: () => [] +} as unknown) as PluginState + +const fakeWalletInfo = ({ + id: 'fake-wallet-id', + type: 'wallet:bitcoin', + keys: {} +} as unknown) as SafeWalletInfo + +const transaction = ({ + txid: TXID, + signedTx: '0100000000' +} as unknown) as EdgeTransaction + +const makeTestServerStates = ( + http: FakeHttp, + httpUris: string[], + options: { + serverConfigs?: ServerConfig[] + nowNodesApiKey?: string | null + } = {} +): ServerStates => { + const { nowNodesApiKey = 'test-key' } = options + const pluginInfo = makeFakePluginInfo() + pluginInfo.engineInfo.serverConfigs = + options.serverConfigs ?? + (httpUris.length > 0 ? [{ type: 'blockbook-nownode', uris: httpUris }] : []) + const serverStates = makeServerStates({ + engineEmitter: new EngineEmitter(), + initOptions: nowNodesApiKey == null ? {} : { nowNodesApiKey }, + io: { ...makeFakeIo(), fetchCors: http.fetchCors }, + log: makeFakeLog(), + pluginInfo, + pluginState: fakePluginState, + walletInfo: fakeWalletInfo + }) + // No engine tasks to run in these tests: + serverStates.setPickNextTaskCB(async function* () { + return false + }) + return serverStates +} + +const waitFor = async ( + predicate: () => boolean, + timeoutMs = 5000 +): Promise => { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('waitFor timed out') + } + await new Promise(resolve => setTimeout(resolve, 25)) + } +} + +describe('ServerStates.broadcastTx', function () { + this.timeout(15000) + + it('broadcasts over every HTTP server when no sockets are cached', async () => { + const http = makeFakeHttp({ + 'https://http-a.test': 'ok', + 'https://http-b.test': 'ok' + }) + const serverStates = makeTestServerStates(http, [ + 'https://http-a.test', + 'https://http-b.test' + ]) + + const result = await serverStates.broadcastTx(transaction) + + expect(result).to.equal(TXID) + expect(http.calls).to.have.members([ + 'https://http-a.test/api/v2/sendtx/', + 'https://http-b.test/api/v2/sendtx/' + ]) + }) + + it('resolves when at least one HTTP server accepts', async () => { + const http = makeFakeHttp({ + 'https://http-a.test': 'fail', + 'https://http-b.test': 'ok' + }) + const serverStates = makeTestServerStates(http, [ + 'https://http-a.test', + 'https://http-b.test' + ]) + + const result = await serverStates.broadcastTx(transaction) + + expect(result).to.equal(TXID) + expect(http.calls).to.have.lengthOf(2) + }) + + it('rejects only after every HTTP attempt fails', async () => { + const http = makeFakeHttp({ + 'https://http-a.test': 'fail', + 'https://http-b.test': 'fail' + }) + const serverStates = makeTestServerStates(http, [ + 'https://http-a.test', + 'https://http-b.test' + ]) + + let error: unknown + try { + await serverStates.broadcastTx(transaction) + } catch (e) { + error = e + } + + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.include('HTTP 500') + expect(http.calls).to.have.lengthOf(2) + }) + + it('sends the api-key header only to NOWNodes servers', async () => { + const http = makeFakeHttp({ + 'https://public.test': 'ok', + 'https://nownodes.test': 'ok' + }) + const serverStates = makeTestServerStates(http, [], { + serverConfigs: [ + { type: 'blockbook', uris: ['https://public.test'] }, + { type: 'blockbook-nownode', uris: ['https://nownodes.test'] } + ] + }) + + await serverStates.broadcastTx(transaction) + await waitFor(() => http.calls.length === 2) + + expect(http.headers['https://public.test/api/v2/sendtx/']).to.deep.equal({}) + expect(http.headers['https://nownodes.test/api/v2/sendtx/']).to.deep.equal({ + 'api-key': 'test-key' + }) + }) + + it('skips NOWNodes servers but still uses public servers without a key', async () => { + const http = makeFakeHttp({ + 'https://public.test': 'ok', + 'https://nownodes.test': 'ok' + }) + const serverStates = makeTestServerStates(http, [], { + nowNodesApiKey: null, + serverConfigs: [ + { type: 'blockbook', uris: ['https://public.test'] }, + { type: 'blockbook-nownode', uris: ['https://nownodes.test'] } + ] + }) + + const result = await serverStates.broadcastTx(transaction) + + expect(result).to.equal(TXID) + expect(http.calls).to.deep.equal(['https://public.test/api/v2/sendtx/']) + }) + + it('rejects when only NOWNodes servers exist and there is no key', async () => { + const http = makeFakeHttp({ 'https://nownodes.test': 'ok' }) + const serverStates = makeTestServerStates(http, ['https://nownodes.test'], { + nowNodesApiKey: null + }) + + let error: unknown + try { + await serverStates.broadcastTx(transaction) + } catch (e) { + error = e + } + + expect((error as Error).message).to.include('No available connections') + expect(http.calls).to.have.lengthOf(0) + }) + + it('rejects immediately when there is nothing to broadcast to', async () => { + const http = makeFakeHttp({}) + const serverStates = makeTestServerStates(http, []) + + let error: unknown + try { + await serverStates.broadcastTx(transaction) + } catch (e) { + error = e + } + + expect((error as Error).message).to.include('No available connections') + expect(http.calls).to.have.lengthOf(0) + }) + + describe('with a connected socket that never answers sendTransaction', () => { + let websocketServer: WS.Server + let serverStates: ServerStates + const receivedMethods: string[] = [] + + beforeEach(async () => { + receivedMethods.length = 0 + websocketServer = new WS.Server({ port: WS_PORT }) + websocketServer.on('connection', (ws: WebSocket) => { + ws.onmessage = event => { + const data = JSON.parse(event.data) + receivedMethods.push(data.method) + switch (data.method) { + case 'ping': + ws.send(JSON.stringify({ id: data.id, data: {} })) + break + case '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 + } + }) + ) + break + case 'sendTransaction': + // Deliberately never answer: this socket looks healthy but + // swallows the broadcast. + break + } + } + }) + await new Promise(resolve => + websocketServer.on('listening', () => { + resolve() + }) + ) + }) + + afterEach(async () => { + serverStates.stop() + await new Promise(resolve => websocketServer.close(() => resolve())) + }) + + it('still resolves through HTTP without waiting on the socket', async () => { + const http = makeFakeHttp({ 'https://http-a.test': 'ok' }) + serverStates = makeTestServerStates(http, ['https://http-a.test']) + serverStates.setServerList([WS_URI]) + serverStates.refillServers() + await waitFor( + () => + serverStates.getServerState(WS_URI)?.blockbook.isConnected === true + ) + + const start = Date.now() + const result = await serverStates.broadcastTx(transaction) + + expect(result).to.equal(TXID) + // Well under the 30s socket request timeout: + expect(Date.now() - start).to.be.lessThan(5000) + // The socket was tried too, in parallel (the frame may still be in + // flight when the HTTP path resolves, so wait for it): + await waitFor(() => receivedMethods.includes('sendTransaction')) + expect(http.calls).to.deep.equal(['https://http-a.test/api/v2/sendtx/']) + }) + }) +})