-
Notifications
You must be signed in to change notification settings - Fork 18
Broadcast to every server at once, and stop leaking the NOWNodes key #458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| .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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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.') | ||
| ) | ||
| } | ||
| }) | ||
| }, | ||
|
|
||
There was a problem hiding this comment.
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.broadcastTxgoes throughpromisifyWsMessage(Blockbook.ts), which awaits aDeferredthat is only ever resolved from inside its own generator. Every failure path inSocket.tsdelivers the error asrequest.generator.throw(err).catch(e => log.error(e.message)): the error-response branch inonMessage, the 30s expiry inonTimer, andonSocketClose. That generator has no try/catch, so the rejection is logged and the deferred is left pending forever. NoonFailureever runs for that attempt,failuresnever reachesattempts, 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 settlesThe deferred bug is pre-existing, but the denominator change is what exposes it. Before this commit, the fully-disconnected case took the
!isAnyBlockbookConnectedbranch, 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 inserverStatesCachenever rejects. Paired with EdgeApp/edge-react-gui#6190 that is a locked slider spinning forever with no message at all, since the scene'sfinallynever 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
promisifyWsMessagereject its deferred when the generator throws.