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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- fixed: Failed sends after a successful broadcast: `saveTx` no longer fails on a disconnected engine, and a broadcast error is reported as a failure only after verifying the transaction is unknown to the network.

## 3.11.0 (2026-07-13)

- added: Support the `<code>-wif:` protohandler prefix (e.g. `bch-wif:`) in `parseUri` so CashStamps private keys can be swept.
Expand Down
87 changes: 77 additions & 10 deletions src/common/utxobased/engine/ServerStates.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { asMaybe, asObject, asString } from 'cleaners'
import { EdgeIo, EdgeLog, EdgeTransaction } from 'edge-core-js/types'
import { parse } from 'uri-js'

Expand Down Expand Up @@ -367,10 +368,76 @@ export function makeServerStates(config: ServerStateConfig): ServerStates {

const instance: ServerStates = {
async broadcastTx(transaction: EdgeTransaction): Promise<string> {
// Query the network for the transaction to determine whether a failed
// broadcast actually reached the network anyway. A server can relay
// the transaction and still return an error or fail to respond, so an
// error from every server does not prove the transaction wasn't sent.
const isTxidKnown = async (txid: string): Promise<boolean> => {
// Ask connected blockbook instances first:
for (const uri of Object.keys(serverStatesCache)) {
const { blockbook } = serverStatesCache[uri]
if (blockbook == null || !blockbook.isConnected) continue
const known = await blockbook
.fetchTransaction(txid)
.then(() => true)
.catch(() => false)
if (known) return true
}
Comment on lines +375 to +385

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.

Unbounded latency on the send path. This loop awaits each blockbook serially, and Socket.ts uses a 30s per-request timeout (constants.ts MAX_CONNECTIONS = 2), so this alone can add ~60s. The NOWNodes loop below then adds N more io.fetchCors calls with no timeout at all.

Before this change a total broadcast failure rejected immediately. Now the user sits on a spinner for a minute or more — potentially forever if a fetchCors never settles — before being told the send failed.

Suggest racing these concurrently and wrapping the whole isTxidKnown call in an overall deadline (~5-10s), falling back to fail() on timeout.


// Fall back to the NOWNode HTTP API when no blockbook is connected:
const { nowNodesApiKey } = initOptions
if (nowNodesApiKey == null) return false
const nowNodeUris = serverConfigs
.filter(config => config.type === 'blockbook-nownode')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why this query is hard-coded to NOWNode: the checker must work in the exact state the incident happened in (zero connected blockbooks), and the only transport the engine is configured to reach there is the NOWNode REST API. The sendtx fallback ~90 lines below already restricts itself to type === 'blockbook-nownode' + nowNodesApiKey (with a commented-out future hook for user-configured HTTP servers), so the query mirrors that exactly: whatever server could have relayed the transaction is the server we ask about it. Connected blockbooks are tried first, so NOWNode is only the last resort for the disconnected state.

Failure direction is safe: with no NOWNode config or key, isTxidKnown returns false and the broadcast rejects with the original error, which is the pre-fix behavior. The hard-coding can cost a missed rescue, never a false success.

Known drift risk: the sendtx fallback and this query each derive the NOWNode uri list + key independently. If the fallback ever broadens to other HTTP servers, this query must broaden with it; a shared resolveHttpFallbackServers() helper would collapse the two sites and is a clean follow-up.

.map(config => config.uris)
.flat(1)
Comment on lines +387 to +393

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.

The comment says "when no blockbook is connected", but there is no such condition — this block runs on every exhausted broadcast, including the case where blockbooks are connected and simply returned false above.

Either gate it on !isAnyBlockbookConnected to match the intent (and match the broadcast fallback at L472), or fix the comment. As-is it silently widens both the latency and the api-key exposure below.

for (const uri of nowNodeUris) {
const known = await io
.fetchCors(`${uri}/api/v2/tx/${txid}`, {
headers: {
'api-key': nowNodesApiKey
}
})
.then(async response => {
if (!response.ok) return false
const json = await response.json()
return asMaybe(asTxQueryResponse)(json)?.txid === txid
})
.catch(() => false)
if (known) return true
}
Comment on lines +394 to +408

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.

The NOWNodes api-key goes to hosts that aren't NOWNodes. ServerConfig['type'] is a single-member union (types.ts:160), so .filter(config => config.type === 'blockbook-nownode') never excludes anything. And those URI lists are mixed — bitcoin.ts:54 has https://btc-wusa1.edge.app, https://btc-eu1.edge.app and https://btcbook.nownodes.io, all under the same type. Same in bitcoincash.ts, etc.

This is pre-existing in the broadcast fallback at L502-512, so not introduced here — but this PR replicates it on a new path that (per the comment above) fires far more often. Worth splitting the config type so the key is only attached to actual nownodes.io hosts, rather than duplicating the pattern.

return false
}

return await new Promise((resolve, reject) => {
let resolved = false
let bad = 0

// Reject with the given error only when the transaction is verifiably
// absent from the network; a transaction that reached the network
// despite the error is a successful broadcast.
const rejectUnlessTxKnown = (error?: Error): void => {
const fail = (): void => {
const msg = error != null ? `With error ${error.message}` : ''
log.error(
`broadcastTx fail: ${JSON.stringify(transaction)}\n${msg}`
)
reject(error)
}
isTxidKnown(transaction.txid)
.then(known => {
if (!known) return fail()
if (!resolved) {
resolved = true
log.warn(
`broadcastTx errored, but txid ${transaction.txid} is known to the network; treating broadcast as a success`
)
resolve(transaction.txid)
}
})
.catch(fail)
Comment on lines +427 to +438

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.

No propagation delay means this will usually return false when it matters. isTxidKnown fires the instant the last broadcast rejects. In the exact race being defended against — the node relayed the tx but the response was lost — the tx has had ~0ms to be indexed into the queried server's mempool, so fetchTransaction / /api/v2/tx/ will very likely 404 and we fail() anyway.

A short delay plus one retry before giving up would make the check actually fire in the target scenario. Without it, the added latency buys little.

Minor: an early if (resolved) return at the top would also skip the whole query in the (currently unreachable, but cheap to guard) case where another server already resolved.

}

const wsUris = Object.keys(serverStatesCache).filter(
uri => serverStatesCache[uri].blockbook != null
)
Expand All @@ -394,11 +461,7 @@ export function makeServerStates(config: ServerStateConfig): ServerStates {
})
.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)
rejectUnlessTxKnown(e)
}
})
}
Expand Down Expand Up @@ -464,11 +527,7 @@ export function makeServerStates(config: ServerStateConfig): ServerStates {
})
.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)
rejectUnlessTxKnown(e)
}
})
}
Expand Down Expand Up @@ -670,3 +729,11 @@ export function makeServerStates(config: ServerStateConfig): ServerStates {

return instance
}

/**
* Minimal shape of a Blockbook REST `/api/v2/tx/<txid>` response, used only
* to confirm that a transaction is known to the network.
*/
const asTxQueryResponse = asObject({
txid: asString
})
6 changes: 5 additions & 1 deletion src/common/utxobased/engine/UtxoEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,11 @@ export async function makeUtxoEngine(
throw err
})
if (id !== transaction.txid) {
throw new Error('broadcast response txid does not match original')
// The transaction is on the network at this point, so a mismatched
// response txid must not be reported as a send failure.
log.warn(
`broadcast response txid mismatch: expected ${transaction.txid} received ${id}`
)
Comment on lines +435 to +439

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.

Agreed that throwing here was wrong post-broadcast, but returning transaction unchanged means the wallet persists transaction.txid while the network accepted id. The wallet then tracks a txid that doesn't exist on-chain: it never confirms, the UTXOs stay marked spent, and the funds look stuck — with no error surfaced anywhere the user can see.

Since formats includes non-segwit (bip44, bip32), a genuine txid change isn't purely hypothetical. Consider returning { ...transaction, txid: id } so the wallet tracks what the network actually has, or at minimum promote this above log.warn.

}
return transaction
},
Expand Down
8 changes: 6 additions & 2 deletions src/common/utxobased/engine/UtxoEngineProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,12 @@ export function makeUtxoEngineProcessor(
// Increment the processed count
processedCount = processedCount + 1

// If we have no addresses, we should not have not yet began processing.
if (expectedProcessCount === 0) throw new Error('No addresses to process')
// With no subscribed addresses there is no denominator to compute a
// progress ratio from. This is a legitimate state when processing is
// driven by saveTx on a disconnected engine (no blockbook sockets, so
// nothing is subscribed), so skip the progress update rather than fail
// the caller's data write.
if (expectedProcessCount === 0) return
Comment on lines +173 to +178

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.

Returning instead of throwing is right, but two things here:

1. The comment's premise is wrong. This is not "a running engine whose sockets are all down." initializeAddressSubscriptions() (L300) populates addressSubscribeCache from the DataLayer with no network involvement, and entries are only ever marked processing, never removed (L936-966). The cache is empty in exactly two states: the engine was never started, or it was stopped (clearTaskCache at L136). A started-but-disconnected engine has a full cache. Worth correcting here and in saveTx.spec.ts:112-113, since it changes how someone reasons about this branch.

2. setLookAhead now runs in a state it never reached before, and the counter is already dirty. Previously the throw on L177 short-circuited processDataLayerUtxos before setLookAhead(common) at L1583. Now it runs. setLookAhead itself is network-free and safe, but it repopulates addressSubscribeCache with the handful of addresses it newly derives — so the denominator goes from 0 to something very small.

Combined with processedCount being incremented above the guard on L171, a single saveTx with two scriptPubkeys (input + change — the common case) does:

  • call 1: expectedProcessCount = 0, processedCount → 1, return; setLookAhead derives 1 new address → cache size 1
  • call 2: expectedProcessCount = 2, processedCount → 2percent === 1

That emits ADDRESSES_CHECKED(1) and calls updateSeenTxCheckpoint() on a stopped engine, advancing the seen-tx checkpoint to maxSeenTxBlockHeight without having synced. Moving the increment below the guard fixes it — a call with no denominator shouldn't count as progress.


const percent = processedCount / expectedProcessCount
if (percent - processedPercent > CACHE_THROTTLE || percent === 1) {
Expand Down
168 changes: 168 additions & 0 deletions test/common/utxobased/engine/saveTx.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { assert } from 'chai'
import { makeMemoryDisklet, makeNodeDisklet } from 'disklet'
import {
EdgeCorePluginOptions,
EdgeCurrencyEngine,
EdgeCurrencyEngineCallbacks,
EdgeCurrencyEngineOptions,
EdgeCurrencyPlugin,
EdgeCurrencyTools,
EdgeTransaction,
JsonObject,
makeFakeIo
} from 'edge-core-js'
import { describe, it } from 'mocha'

import edgeCorePlugins from '../../../../src/index'
import { noOp, testLog } from '../../../util/testLog'
import { makeFakeNativeIo } from '../../../utils'
import { fixtures } from './engine.fixtures/index'

const [tests] = fixtures

/**
* A transaction paying to an address that the dummy-data wallet owns
* (scriptPubkey a9142244... with a bip49 path), spending a UTXO that the
* dummy-data set holds (19e59364...:0). This makes saveTx's
* getOwnUtxosFromTx return both a spent input and a new output, which drives
* processUtxos -> processDataLayerUtxos -> updateProgressRatio.
*/
const OWN_SCRIPT_PUBKEY = 'a9142244ce86d664e85801f7eb2a56dd35afd268212587'
const SPENT_TXID =
'19e59364daf34d97ed6584e9e978f3e2375adea9a4561a83d2066d92a010ba13'
const NEW_TXID =
'f00dbabef00dbabef00dbabef00dbabef00dbabef00dbabef00dbabef00dbabe'

describe('saveTx on a disconnected engine', function () {
it('resolves with zero subscribed addresses', async function () {
this.timeout(10000)

const fakeIo = makeFakeIo()
const fixtureDisklet = makeNodeDisklet(tests.dummyDataPath)
const fakeIoDisklet = makeMemoryDisklet()
const nativeIo = makeFakeNativeIo()

// Preload the wallet's data layer with the dummy dataset so the wallet
// owns addresses and UTXOs:
const migrate = async (dir: string): Promise<void> => {
const files = await fixtureDisklet.list(dir)
await Promise.all(
Object.entries(files).map(async ([path, type]) => {
if (type === 'folder') await migrate(path)
if (type === 'file')
await fixtureDisklet
.getText(path)
.then(async data => await fakeIoDisklet.setText(path, data))
})
)
}
await migrate('tables')

const pluginOpts: EdgeCorePluginOptions = {
initOptions: {},
io: {
...fakeIo,
random: () => Uint8Array.from(tests.key)
},
log: testLog,
infoPayload: {},
nativeIo,
pluginDisklet: fakeIoDisklet
}
const factory = edgeCorePlugins[tests.pluginId]
if (typeof factory !== 'function')
throw new Error(`Missing plugin factory for ${tests.pluginId}`)
const plugin: EdgeCurrencyPlugin = factory(pluginOpts) as any

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.

const plugin: EdgeCurrencyPlugin = factory(pluginOpts) as any — the annotation and the as any cancel each other out. factory returns EdgeCorePlugin; narrowing with a type guard (or as EdgeCurrencyPlugin) keeps the assertion visible instead of disabling checking entirely.


const tools: EdgeCurrencyTools = await plugin.makeCurrencyTools()
const privateKeys = await tools.createPrivateKey(tests.WALLET_TYPE)
Object.assign(privateKeys, { coinType: 0, format: tests.WALLET_FORMAT })
const publicKeys = await tools.derivePublicKey({
type: tests.WALLET_TYPE,
keys: privateKeys,
id: '!'
})
const keys: JsonObject = { ...privateKeys, ...publicKeys }

const callbacks: EdgeCurrencyEngineCallbacks = {
onAddressChanged: noOp,
onAddressesChecked: noOp,
onBalanceChanged: noOp,
onBlockHeightChanged: noOp,
onNewTokens: noOp,
onSeenTxCheckpoint: noOp,
onStakingStatusChanged: noOp,
onTokenBalanceChanged: noOp,
onTransactions: noOp,
onTransactionsChanged: noOp,
onTxidsChanged: noOp,
onUnactivatedTokenIdsChanged: noOp,
onWcNewContractCall: noOp
}
const engineOpts: EdgeCurrencyEngineOptions = {
callbacks,
log: testLog,
walletLocalDisklet: fakeIoDisklet,
walletLocalEncryptedDisklet: fakeIoDisklet,
customTokens: {},
enabledTokenIds: [],
userSettings: {}
}

// The engine is never started, so no blockbook connects and no address
// is ever subscribed. This is the same state as a running engine whose
// sockets are all down: taskCache.addressSubscribeCache is empty.
const engine: EdgeCurrencyEngine = await plugin.makeCurrencyEngine(
{ type: tests.WALLET_TYPE, keys, id: '!' },
engineOpts
)

const scriptPubkeyBuffer = Buffer.from(OWN_SCRIPT_PUBKEY, 'hex')
const edgeTx: EdgeTransaction = {
blockHeight: 0,
currencyCode: 'TESTBTC',
date: 1723000000,
isSend: true,
memos: [],
nativeAmount: '-50000',
networkFee: '1000',
networkFees: [],
otherParams: {
psbt: {
base64: '',
inputs: [
{
hash: Buffer.from(SPENT_TXID, 'hex').reverse(),
index: 0,
value: 16250000,
scriptPubkey: scriptPubkeyBuffer,
sequence: 0xffffffff
}
],
outputs: [
{
value: 16200000,
scriptPubkey: scriptPubkeyBuffer
}
]
}
},
ourReceiveAddresses: [],
signedTx: '0100000000',
tokenId: null,
txid: NEW_TXID,
walletId: '!'
}

// The regression under test: updateProgressRatio used to throw
// 'No addresses to process' here, failing saveTx AFTER the transaction
// had already been saved and its inputs marked spent.
await engine.saveTx(edgeTx)

const txs = await engine.getTransactions({ tokenId: null })
assert.isTrue(
txs.some(tx => tx.txid === NEW_TXID),
'saved transaction should be listed'
)
})
})
Loading