floor the electrum batch page size at one when halving it after a timeout
What changed, and why it matters
This commit fixes a bug in Sparrow Wallet's Electrum server batching logic. When the wallet had experienced network timeouts, it tried to shrink its request batch size by half. If the configured page size was already 1, halving it produced 0, which then caused every future batched request to fail for the rest of the session. The fix ensures the page size never drops below 1. This is a reliability/denial-of-service bug rather than a way for an attacker to steal funds directly.
Users and downstream packagers should upgrade to a release containing this commit. Operators relying on low page sizes or unstable Electrum server connections are most affected. No immediate workaround is required, but verify the fix is included before the next release.
Security signals we found
Integer underflow-like behavior leading to zero batch size
Persistent denial of service for wallet synchronization after timeout
Third-party library precondition failure (Lists.partition) triggered by application logic
Fix is a hard floor rather than a redesign of timeout handling
Evidence from the diff
PagedBatchRequestBuilder.getPageSize() previously returned pageSize / 2 after any transport timeout. With pageSize=1, integer division yields 0. The resulting 0 is passed to Lists.partition, which throws IllegalArgumentException, causing all subsequent paged Electrum batch requests to fail while the session remains open. The patch floors the halved value at 1 using Math.max(1, pageSize / 2), and adds unit tests covering both the floor-at-one case and normal halving for larger page sizes.
Changed components
com.sparrowwallet.sparrow.net.PagedBatchRequestBuilderElectrum server batch request pagingWallet synchronization / server communicationInspect captured patch +37 / −4
### src/main/java/com/sparrowwallet/sparrow/net/PagedBatchRequestBuilder.java
@@ -229,11 +229,11 @@ private int getPageSize() {
pageSize = DEFAULT_PAGE_SIZE;
}
- //Halve the page size if there have been timeouts
+ //Halve the page size if there have been timeouts, but never below one request, which is as small as a page can be
if(transport instanceof TimeoutCounter timeoutCounter) {
int timeouts = timeoutCounter.getTimeoutCount();
if(timeouts > 0) {
- return pageSize / 2;
+ return Math.max(1, pageSize / 2);
}
}
### src/test/java/com/sparrowwallet/sparrow/net/PagedBatchRequestBuilderTest.java
@@ -130,10 +130,29 @@ public void pageSizeSurvivesTheBuilderCopies() throws Exception {
assertEquals(2, transport.requests.size(), "pageSize set before keysType and returnType must be carried onto the copies they return");
}
- @SuppressWarnings("unchecked")
+ /**
+ * A timeout halves the page size, and a page size of one halved to zero, which Lists.partition rejects - failing every paged request for the rest of
+ * the session on a configured maximum of one. One request is as small as a page can be, and a larger size is still halved.
+ */
+ @Test
+ public void pageSizeHalvedAfterATimeoutIsNeverBelowOne() throws Exception {
+ FakeBatchTransport transport = new TimingOutBatchTransport();
+ builder(transport, 1).executeTolerant(1, "ERROR", e -> {});
+ assertEquals(6, transport.requests.size());
+
+ FakeBatchTransport halved = new TimingOutBatchTransport();
+ builder(halved, 4).executeTolerant(1, "ERROR", e -> {});
+ assertEquals(3, halved.requests.size());
+ }
+
private PagedBatchRequestBuilder<String, String> builder(Transport transport) {
+ return builder(transport, 2);
+ }
+
+ @SuppressWarnings("unchecked")
+ private PagedBatchRequestBuilder<String, String> builder(Transport transport, int pageSize) {
PagedBatchRequestBuilder<String, String> batchRequest =
- (PagedBatchRequestBuilder<String, String>)PagedBatchRequestBuilder.create(transport, new AtomicLong()).keysType(String.class).returnType(String.class).pageSize(2);
+ (PagedBatchRequestBuilder<String, String>)PagedBatchRequestBuilder.create(transport, new AtomicLong()).keysType(String.class).returnType(String.class).pageSize(pageSize);
for(String id : List.of("a", "b", "c", "d", "e", "f")) {
batchRequest.add(id, "test.method", id);
}
@@ -182,4 +201,18 @@ public String pass(String request) throws IOException {
return MAPPER.writeValueAsString(responses);
}
}
+
+ /**
+ * A transport that has seen a timeout, which is what the page size is halved on.
+ */
+ private static class TimingOutBatchTransport extends FakeBatchTransport implements TimeoutCounter {
+ TimingOutBatchTransport() {
+ super(Set.of(), 0);
+ }
+
+ @Override
+ public int getTimeoutCount() {
+ return 1;
+ }
+ }
}Why this scored 33/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.