fix(wallet-toolbox): pass trx to findProvenTxs in getProvenOrRawTx - #439
fix(wallet-toolbox): pass trx to findProvenTxs in getProvenOrRawTx#439imranterranode wants to merge 1 commit into
Conversation
getProvenOrRawTx(txid, trx) receives a database transaction and binds it
for its second query (proven_tx_reqs) but drops it on the first, so the
proven_txs lookup runs on this.knex instead of the caller's transaction.
findProvenTxs -> findProvenTxsQuery -> setupQuery uses this.toDb(args.trx),
so with args.trx absent the query asks the pool for a second connection
while the open transaction still holds the first. knex forces {min:1,max:1}
on the sqlite dialect, so that connection can never be granted: the
transaction will not release until the query returns, and the query cannot
run until the transaction releases. After acquireConnectionTimeout it fails
with "KnexTimeoutError: Timeout acquiring a connection. The pool is probably
full. Are you missing a .transacting(trx) call?".
The caller is the proof-completion path:
findTransactions(.., trx) -> validateRawTransaction(t, trx)
-> getRawTxOfKnownValidTransaction(.., trx) -> getProvenOrRawTx(txid, trx)
so TaskCheckForProofs dies before processing any request, leaving the
proven_tx_reqs backlog at zero attempts indefinitely. Downstream this makes
BEEF payment headers grow without bound, because ancestry stops at the first
parent holding a merkle proof and those proofs never arrive.
Reproduced independently of any app: a standalone script on a copy of a real
wallet database deadlocks with 343 outstanding requests and also with 5, so
this is not backlog volume but a path that never completes.
Verified in a live BSV Desktop wallet (2.4.4, better-sqlite3) that had been
stuck for the whole session:
proven_tx_reqs completed 18 -> 119
outstanding (unmined) 139 -> 38
proven_txs 19 -> 120
monitor KnexTimeoutErrors 27 -> 27 (none new)
Payment header fell 33,058 -> 25,494 -> 13,666 bytes, and a wallet that
Cloudflare had been refusing at its 32KB request-header cap began paying
again on the first attempt.
Unchanged in 2.4.4 and 2.6.1, and shared by every consumer on SQLite.
|
sirdeggen
left a comment
There was a problem hiding this comment.
Review: the fix is correct, and it is already on main
The diagnosis holds up and the write-up is excellent — but this exact change landed three days before this PR was opened.
main today, packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts:149:
r.proven = verifyOneOrNone(await this.findProvenTxs({ partial: { txid }, trx }))git log -L 149,149:packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts attributes it to ef710c3 — "perf(wallet): bring createAction under remote latency budgets" (#426), merged 2026-08-04, which made the identical { partial: { txid } } → { partial: { txid }, trx } edit. It was a one-line drive-by inside a larger perf PR, which is presumably why it wasn't visible when this was found.
This branch forked from main before #426, so GitHub still renders the change as a 1-line diff against the fork point. Merged, it would be a no-op.
Why CI looks the way it does
- SonarCloud — "E Maintainability Rating on New Code" is not about the changed line. The branch is far enough behind
mainthat Sonar's "new code" set is much larger than the diff.mergeable_stateisbehind; a rebase onto currentmainshould clear it (and would also reduce this PR to an empty diff). - The main
ci.ymlworkflow never ran here — only Sonar and the two Socket checks. Fork PRs need a maintainer to approve workflow runs.
So there is nothing to fix on the code side, and nothing I can push: the head branch lives on a fork.
The follow-up in the description is still open, and still real
readSettings() on main (line 137) is unchanged:
async readSettings (): Promise<TableSettings> {
return this.validateEntity(verifyOne(await this.toDb()<TableSettings>('settings')))
}No trx parameter, so toDb() resolves to the pool. It is reached from verifyReadyForDatabaseAccess, which every write path calls, so the self-deadlock described here is reachable whenever a StorageKnex instance's first settings read happens inside a transaction on SQLite's forced { min: 1, max: 1 } pool. It is the only remaining toDb() call in the file that cannot receive a caller's transaction. That is worth its own issue or PR — and it is where the "known unknown" energy in this description would pay off.
The regression test you sketched (lower acquireConnectionTimeout, open a knex.transaction, call inside it, assert it resolves) also still has value, since #426 fixed the line without locking the behaviour down. Written against readSettings it would cover both.
Recommendation
Close this PR as already-fixed, and open a follow-up for readSettings() plus the regression test. The investigation stands on its own regardless — the mainnet header-growth measurements and the "Cloudflare refusal masquerades as an auth failure" finding are worth preserving somewhere durable, since they explain a failure mode that is genuinely hard to recognise from the error text.
Generated by Claude Code
|
Verified — this landed on For anyone who finds this later: the published npm Follow-up for |




Summary
StorageKnex.getProvenOrRawTx(txid, trx)receives a database transaction, uses it for its second query, and drops it on the first. On SQLite that deadlocks the connection pool and kills the proof-fetching task before it processes a single request.The consequence, downstream, is that BEEF payment headers grow without bound until Cloudflare's 32 KB request-header cap refuses them — at which point the wallet cannot pay at all, and cannot recover by retrying.
One line.
trxis already a parameter of the enclosing function.The mechanism
findProvenTxs→findProvenTxsQuery→setupQuery, which builds the query asthis.toDb(args.trx)(table). Withargs.trxabsent,toDb(undefined)returnsthis.knex— the pool, not the caller's transaction.knex forces
{ min: 1, max: 1 }on the sqlite dialect (knex/lib/dialects/sqlite3/index.js), so there is exactly one connection. The caller's transaction is holding it. This query then waits for a second connection that cannot be granted: the transaction will not release until the query returns, and the query cannot run until the transaction releases.After
acquireConnectionTimeout(60s default) it throws:The caller is the proof-completion path:
So
TaskCheckForProofsdies before touching any request. That is why an affected wallet'sproven_tx_reqsbacklog sits at zero attempts — the rows are not being tried and failing, they are never reached.Why this matters beyond the deadlock
A payment transaction must prove its inputs are real without the server asking anyone. A confirmed input needs only a merkle path (a few hundred bytes); an unconfirmed one requires the entire parent transaction, and proof of its inputs, recursively.
Ancestry therefore stops at the first parent holding a merkle proof. When proofs never arrive, every payment re-ships the whole unproven chain. Measured on mainnet: +~300 bytes per payment, linearly, with no upper bound, crossing 32 KB around the 80th consecutive purchase.
Two properties make it worse than a slow leak:
missing headers: x-bsv-auth-version, x-bsv-auth-identity-key, x-bsv-auth-signature. It reads like an authentication failure, and anyone hitting it goes looking in the wrong place.Evidence
Reproduced away from any app — standalone script, plain Node, no Electron, no other process on the database, default pool, running the proof task against a copy of a real wallet database:
2.4.4KnexTimeoutErrorafter 60s2.4.4KnexTimeoutErrorafter 60strxpassedIt is not caused by backlog size. Five outstanding requests deadlock exactly as 343 do — this is a code path that does not complete, not a wallet that has been overwhelmed. Volume only changed how visible it was.
Verified live, in a BSV Desktop wallet pinned to
2.4.4usingbetter-sqlite3(a different driver from the harness above, so two independent environments failing the same way). The wallet had been stuck for hours, withTaskCheckForProofsandReviewStatusthrowingKnexTimeoutErrorroughly every 90 seconds:proven_tx_reqscompletedunmined)proven_txsKnexTimeoutErrorevents101 proofs drained within minutes of restarting, against a backlog that had previously moved 20–40 per day.
Functional result. The same wallet was at a 33,058-byte payment header and was being refused by Cloudflare (
400 Request Header Or Cookie Too Large, unsigned HTML fromcdn-cgi— confirming the refusal happens at the edge and never reaches the API). After the fix:Walking the ancestor chain the way the payment builder does, on a wallet drained to zero outstanding, predicts a header of ~1,600 bytes versus ~35,300 unrepaired — and that unrepaired prediction came within 1% of a header measured independently on the live edge.
Scope and limitations
2.4.4and2.6.1, so upgrading does not avoid it. The path is shared by every consumer on SQLite.Known unknown, stated rather than smoothed over
If this path deadlocks with five requests, it should deadlock with one — yet proofs do complete for affected wallets (124 over a month in the wallet studied, and during live verification above,
completedrose while the task was still throwing). So a second route finishes some requests without entering the transaction path — most likelygetProofscompleting a request already linked to a proven transaction.That does not weaken the case for this change: the deadlock is real, reproducible at any backlog size, and removing it demonstrably drains a queue that was otherwise stationary. But it means this fix is necessary and measurably effective, and may not be the whole story for proof-drain throughput.
Separately,
readSettings()(immediately above the patched function) hardcodesthis.toDb(undefined)and takes notrxparameter at all, so anyStorageKnexwhose first database access occurs inside a transaction self-deadlocks identically. It is reached fromverifyReadyForDatabaseAccess, which every write passes through. Left out of this PR deliberately to keep the change to a single reviewable line; worth a follow-up.Testing
Type-safe by construction:
FindProvenTxsArgs extends FindSincePagedArgs, which declarestrx?: TrxToken— the same type as the enclosing function's own parameter.Build and full test suite run on this branch (Node 24.13.0, pnpm 10.33.2):
No failures, no new skips.
There is no regression test specific to this fix, and it is worth being explicit about why: reproducing it requires a transaction held open against a single-connection pool while a second query is issued, and asserting on a 60-second
acquireConnectionTimeout. That is doable — loweracquireConnectionTimeout, open aknex.transaction, callgetProvenOrRawTxinside it, assert it resolves rather than throwingKnexTimeoutError— but it is a slow, timing-shaped test and I would rather reviewers decide whether it belongs here or as a follow-up. Happy to add it to this PR on request.