Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/common/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}

Expand Down
209 changes: 112 additions & 97 deletions src/common/utxobased/engine/ServerStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failing WebSocket attempt never settles, so this promise can hang instead of rejecting.

blockbook.broadcastTx goes through promisifyWsMessage (Blockbook.ts), which awaits a Deferred that is only ever resolved from inside its own generator. Every failure path in Socket.ts delivers the error as request.generator.throw(err).catch(e => log.error(e.message)): the error-response branch in onMessage, the 30s expiry in onTimer, and onSocketClose. That generator has no try/catch, so the rejection is logged and the deferred is left pending forever. No onFailure ever runs for that attempt, failures never reaches attempts, and the promise settles neither way.

sequenceDiagram
    participant SS as ServerStates.broadcastTx
    participant Sock as Socket blockbook
    participant Http as HTTP servers
    SS->>Sock: broadcastTx, attempts = 1
    SS->>Http: sendtx, attempts = 2
    Http-->>SS: rejects, onFailure, failures = 1
    Sock->>Sock: 30s expiry, generator.throw
    Sock->>Sock: Deferred left pending, no onFailure
    SS->>SS: failures 1 < attempts 2, never settles
Loading

The deferred bug is pre-existing, but the denominator change is what exposes it. Before this commit, the fully-disconnected case took the !isAnyBlockbookConnected branch, skipped sockets entirely and rejected off the HTTP count, so a genuinely failed broadcast surfaced as a failure. Sockets are now always counted, so an all-fail broadcast with any entry in serverStatesCache never rejects. Paired with EdgeApp/edge-react-gui#6190 that is a locked slider spinning forever with no message at all, since the scene's finally never runs.

The new spec covers socket-hangs-but-HTTP-succeeds; it is the all-fail variant that hangs. Either bound each attempt before counting it, or have promisifyWsMessage reject its deferred when the generator throws.

.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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every send now discloses the transaction to the NOWNodes HTTP endpoint even when the sockets and Edge's own servers are healthy, where HTTP was previously a fallback only. For BTC that is btc-wusa1.edge.app, btc-eu1.edge.app and btcbook.nownodes.io on every broadcast, unconditionally.

Unblocking HTTP is the right call; making it unconditional on the happy path is a separate product decision that the PR description does not call out. Worth confirming it is intended, or staggering the third-party targets behind a short delay so they only fire when the first wave has not resolved.

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.')
)
}
})
},
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/bitcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/bitcoincash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const currencyInfo: EdgeCurrencyInfo = {
const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://bch-eusa1.edge.app']
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/dash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const currencyInfo: EdgeCurrencyInfo = {
const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://dash-wusa1.edge.app']
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/digibyte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const currencyInfo: EdgeCurrencyInfo = {
const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://dgb-eu1.edge.app']
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/dogecoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const currencyInfo: EdgeCurrencyInfo = {
const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://doge-eusa1.edge.app']
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/litecoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const currencyInfo: EdgeCurrencyInfo = {
export const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://ltc-wusa1.edge.app']
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/pivx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const currencyInfo: EdgeCurrencyInfo = {
const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://pivx-wusa1.edge.app']
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/qtum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const currencyInfo: EdgeCurrencyInfo = {
const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://qtum-wusa1.edge.app']
}
],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/vertcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const currencyInfo: EdgeCurrencyInfo = {
const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://vtc-wusa1.edge.app']
}
],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/zcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const currencyInfo: EdgeCurrencyInfo = {
export const engineInfo: EngineInfo = {
serverConfigs: [
{
type: 'blockbook-nownode',
type: 'blockbook',
uris: ['https://firo-eusa1.edge.app']
},
{
Expand Down
Loading
Loading