From d242531e2df190485e7a63ad9e823be60199201b Mon Sep 17 00:00:00 2001 From: peachbits Date: Wed, 2 Sep 2026 22:07:19 -0700 Subject: [PATCH 1/2] Send the NOWNodes API key only to NOWNodes servers `ServerConfig` had a single type, `blockbook-nownode`, so when Edge's own Blockbook hosts were added in Dec 2024 (9962d7e) they were filed under it alongside the real NOWNodes entries. Every HTTP broadcast therefore sent the NOWNodes key to Edge's servers as well. They are not the same kind of server: GET https://btc-wusa1.edge.app/api/v2 -> 200, no key needed GET https://btcbook.nownodes.io/api/v2 -> 401 Unknown API_key The type becomes `blockbook | blockbook-nownode`. The ten edge.app HTTP entries are now plain `blockbook`, the nownodes.io entries are unchanged, and the key is attached per target rather than to the whole batch. This also fixes the no-key case. A missing `nowNodesApiKey` previously rejected the entire HTTP path with "Missing connection key for fallback servers", even though the public servers need no key. NOWNodes targets are now skipped with a warning and the public servers still carry the broadcast; the reject is reserved for having no usable server at all. Adds test/common/utxobased/engine/ServerStates.spec.ts covering the header routing, both no-key cases, and the existing HTTP broadcast behavior it has to preserve. --- CHANGELOG.md | 2 + src/common/plugin/types.ts | 8 +- src/common/utxobased/engine/ServerStates.ts | 55 ++-- src/common/utxobased/info/bitcoin.ts | 2 +- src/common/utxobased/info/bitcoincash.ts | 2 +- src/common/utxobased/info/dash.ts | 2 +- src/common/utxobased/info/digibyte.ts | 2 +- src/common/utxobased/info/dogecoin.ts | 2 +- src/common/utxobased/info/litecoin.ts | 2 +- src/common/utxobased/info/pivx.ts | 2 +- src/common/utxobased/info/qtum.ts | 2 +- src/common/utxobased/info/vertcoin.ts | 2 +- src/common/utxobased/info/zcoin.ts | 2 +- .../utxobased/engine/ServerStates.spec.ts | 252 ++++++++++++++++++ 14 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 test/common/utxobased/engine/ServerStates.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d3fbe4..123cd84d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 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..edb7e353 100644 --- a/src/common/utxobased/engine/ServerStates.ts +++ b/src/common/utxobased/engine/ServerStates.ts @@ -414,13 +414,40 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { // ]) 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) { + // Build the HTTP targets, attaching the NOWNodes key ONLY to + // NOWNodes servers. Edge's own Blockbook servers answer /api/v2 + // unauthenticated and must not be sent the key. + const httpTargets: Array<{ + uri: string + headers: { [key: string]: string } + }> = [] + for (const config of serverConfigs) { + if (config.type === 'blockbook-nownode') { + // NOWNodes answers 401 without a key, so skip those servers + // rather than failing a broadcast the public servers could + // still carry. + 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: {} }) + } + } + } + + // If there are no usable HTTP servers, reject the promise + if (httpTargets.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. @@ -430,21 +457,13 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { return } - // If there is no key for the NowNode servers: - if (nowNodesApiKey == null) { - reject(new Error('Missing connection key for fallback servers.')) - return - } - - for (const uri of nowNodeUris) { - log.warn('Falling back to NOWNode server broadcast over HTTP:', uri) + for (const { uri, headers } of httpTargets) { + log.warn('Falling back to server broadcast over HTTP:', uri) // HTTP Fallback io.fetchCors(`${uri}/api/v2/sendtx/`, { method: 'POST', - headers: { - 'api-key': nowNodesApiKey - }, + headers, body: transaction.signedTx }) .then(async response => { @@ -463,7 +482,7 @@ export function makeServerStates(config: ServerStateConfig): ServerStates { } }) .catch((e?: Error) => { - if (++bad === nowNodeUris.length) { + if (++bad === httpTargets.length) { const msg = e != null ? `With error ${e.message}` : '' log.error( `broadcastTx fail: ${JSON.stringify(transaction)}\n${msg}` 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..5054d856 --- /dev/null +++ b/test/common/utxobased/engine/ServerStates.spec.ts @@ -0,0 +1,252 @@ +import { expect } from 'chai' +import { makeFakeIo } from 'edge-core-js' +import { + EdgeFetchOptions, + EdgeFetchResponse, + EdgeTransaction +} from 'edge-core-js/types' + +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' + +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) + }) +}) From f809273f3618b6e6a249d017bfa6dabfeaf6d882 Mon Sep 17 00:00:00 2001 From: peachbits Date: Wed, 2 Sep 2026 22:08:15 -0700 Subject: [PATCH 2/2] Broadcast to every server at once instead of gating HTTP on the sockets `broadcastTx` used the HTTP servers only when no WebSocket reported itself connected. A socket that looks connected but never answers `sendTransaction` therefore failed the entire broadcast, after the 30 second request timeout, without a single HTTP attempt being made. That is the shape of the incident this task exists for (Asana 1217135300337949): the send failed in the UI while the transaction was already on the network. The gate is worse than it looks, because the NOWNodes WebSocket URI is docked 400 points at score load (ServerScores.serverScoresLoad penalises any URI carrying key params) and so rarely wins one of the two connection slots. HTTP is the realistic route to those servers, and it was reachable only when every socket was visibly down. broadcastTx now fires at every cached blockbook and every HTTP server in parallel. Sockets that are still connecting are included, since a queued request transmits as soon as the socket opens and times out otherwise. The first success resolves. It rejects only once every attempt has failed, logging each failure against the server that produced it. Extends the ServerStates spec with a real WebSocket server that accepts `sendTransaction` and never answers, asserting the broadcast still resolves over HTTP in well under the socket timeout. --- CHANGELOG.md | 1 + src/common/utxobased/engine/ServerStates.ts | 228 +++++++++--------- .../utxobased/engine/ServerStates.spec.ts | 78 ++++++ 3 files changed, 191 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 123cd84d..2f249d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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) diff --git a/src/common/utxobased/engine/ServerStates.ts b/src/common/utxobased/engine/ServerStates.ts index edb7e353..27f624e3 100644 --- a/src/common/utxobased/engine/ServerStates.ts +++ b/src/common/utxobased/engine/ServerStates.ts @@ -366,131 +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 - - // Build the HTTP targets, attaching the NOWNodes key ONLY to - // NOWNodes servers. Edge's own Blockbook servers answer /api/v2 - // unauthenticated and must not be sent the key. - const httpTargets: Array<{ - uri: string - headers: { [key: string]: string } - }> = [] - for (const config of serverConfigs) { - if (config.type === 'blockbook-nownode') { - // NOWNodes answers 401 without a key, so skip those servers - // rather than failing a broadcast the public servers could - // still carry. - 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: {} }) - } - } - } + // + // 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 are no usable HTTP servers, reject the promise - if (httpTargets.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 + // + // 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, headers } of httpTargets) { - log.warn('Falling back to server broadcast over HTTP:', uri) - - // HTTP Fallback - io.fetchCors(`${uri}/api/v2/sendtx/`, { - method: 'POST', - headers, - 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 === httpTargets.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/test/common/utxobased/engine/ServerStates.spec.ts b/test/common/utxobased/engine/ServerStates.spec.ts index 5054d856..46034b7d 100644 --- a/test/common/utxobased/engine/ServerStates.spec.ts +++ b/test/common/utxobased/engine/ServerStates.spec.ts @@ -5,6 +5,7 @@ import { 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' @@ -17,6 +18,8 @@ import { SafeWalletInfo } from '../../../../src/common/utxobased/keymanager/clea import { makeFakeLog, makeFakePluginInfo } from '../../../utils' const TXID = 'deadbeef' +const WS_PORT = 8556 +const WS_URI = `ws://localhost:${WS_PORT}` type HttpBehavior = 'ok' | 'fail' @@ -249,4 +252,79 @@ describe('ServerStates.broadcastTx', function () { 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/']) + }) + }) })