<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[BlockVectra Engineering]]></title><description><![CDATA[BlockVectra Engineering]]></description><link>https://blockvectra.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 23 Oct 2026 09:15:10 GMT</lastBuildDate><atom:link href="https://blockvectra.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[JSON-RPC batch responses: match by id, not array position]]></title><description><![CDATA[A strict, dependency-free correlator for out-of-order success and error replies]]></description><link>https://blockvectra.hashnode.dev/json-rpc-batch-responses-match-by-id</link><guid isPermaLink="true">https://blockvectra.hashnode.dev/json-rpc-batch-responses-match-by-id</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Ethereum]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[BlockVectra Engineering]]></dc:creator><pubDate>Tue, 20 Oct 2026 19:43:40 GMT</pubDate><content:encoded><![CDATA[<p>By BlockVectra, with AI-assisted drafting.</p>
<p>A JSON-RPC batch puts several calls in one request. The reply array is not a positional mirror of the request array. Treating <code>payload[i]</code> as the answer to <code>requests[i]</code> can bind every result to the wrong call while the transport still reports success. Here is a three-response mismatch:</p>
<pre><code class="language-js">const requests = [
  { jsonrpc: "2.0", id: 7, method: "eth_blockNumber", params: [] },
  { jsonrpc: "2.0", id: 8, method: "eth_getBalance", params: ["0x0000000000000000000000000000000000000000", "latest"] },
  { jsonrpc: "2.0", id: 9, method: "eth_chainId", params: [] },
];

const payload = [
  { jsonrpc: "2.0", id: 9, result: "0x1" },
  { jsonrpc: "2.0", id: 7, error: { code: -32000, message: "busy" } },
  { jsonrpc: "2.0", id: 8, result: "0x0" },
];

requests.forEach((request, index) =&gt; {
  const response = payload[index];
  console.log(request.id, response.id);
});
// 7 9
// 8 7
// 9 8
</code></pre>
<p>Every request is paired with another request's id. Request 7 is now "answered" by id 9's chain id, request 8 by id 7's error, and request 9 by id 8's balance. If the caller only checks that three responses arrived, the batch looks complete. A downstream parser may then apply an <code>eth_chainId</code> result where an <code>eth_blockNumber</code> result was expected. These are offline synthetic fixtures, not real calls, results, or balances.</p>
<p>The specification permits this: response objects "MAY be returned in any order within the Array." The stable join key is the <code>id</code> carried by both the request and its response. A response's position carries no contract.</p>
<h2>HTTP 200 is not batch success</h2>
<p>A batch can return HTTP 200 while containing an item-level error. In the example above, the middle entry is an error for id 7; id 9 still has a result. A status-code check sees only transport success. Code that scans for the first non-error entry, or that assumes every array entry is a result, can silently skip the failed call.</p>
<p>The same rule applies to completeness. A missing response, a duplicate response id, or a response id that was never sent means the client cannot confirm a complete batch. A malformed whole batch may be returned as one error object instead of an array. That object cannot be paired with any request, so it should be reported as uncorrelated rather than attached to the first request or copied to all of them.</p>
<p>Each response must carry exactly one of <code>result</code> or <code>error</code>, and an error must be a non-null object with an integer <code>code</code> and a string <code>message</code>. A <code>result</code> is allowed to be <code>false</code>, <code>0</code>, or <code>null</code>; checking truthiness is not a valid presence test.</p>
<h2>A strict correlator</h2>
<p>The complete implementation follows. It is dependency-free and can be copied as one module.</p>
<pre><code class="language-js">const hasOwn = (value, key) =&gt; Object.prototype.hasOwnProperty.call(value, key);

const isPositiveSafeInt = (value) =&gt; Number.isSafeInteger(value) &amp;&amp; value &gt; 0;

function fail(message) {
  throw new Error(`correlateBatch: ${message}`);
}

function assertErrorShape(error, id) {
  if (error === null || typeof error !== "object" || Array.isArray(error)) {
    fail(`request ${id} returned a non-object error`);
  }
  if (!Number.isInteger(error.code) || typeof error.message !== "string") {
    fail(`request ${id} returned a malformed error`);
  }
}

/**
 * Correlate a JSON-RPC 2.0 batch response array with its requests by id.
 * This checks envelope, id, and error shape only, not result semantics.
 */
export function correlateBatch(requests, payload) {
  if (!Array.isArray(requests) || requests.length === 0) {
    fail("requests must be a non-empty array");
  }
  const expected = new Set();
  for (const request of requests) {
    if (request === null || typeof request !== "object" || !hasOwn(request, "id")) {
      fail("every request must be an object with its own id");
    }
    if (!isPositiveSafeInt(request.id) || expected.has(request.id)) {
      fail("request ids must be unique positive safe integers");
    }
    expected.add(request.id);
  }
  if (!Array.isArray(payload) || payload.length === 0) {
    fail("uncorrelated: response payload must be a non-empty array");
  }
  const responses = new Map();
  for (const row of payload) {
    if (row === null || typeof row !== "object" || Array.isArray(row)) {
      fail("every response row must be a non-null object");
    }
    if (row.jsonrpc !== "2.0") {
      fail("every response row must declare jsonrpc 2.0");
    }
    if (!hasOwn(row, "id")) {
      fail("every response row must carry its own id");
    }
    if (!expected.has(row.id)) {
      fail(`unknown response id ${String(row.id)}`);
    }
    if (responses.has(row.id)) {
      fail(`duplicate response id ${row.id}`);
    }
    const hasResult = hasOwn(row, "result");
    const hasError = hasOwn(row, "error");
    if (hasResult === hasError) {
      fail(`request ${row.id} must carry exactly one of result or error`);
    }
    if (hasError) {
      assertErrorShape(row.error, row.id);
    }
    responses.set(row.id, row);
  }
  for (const id of expected) {
    if (!responses.has(id)) {
      fail(`missing response id ${id}`);
    }
  }
  return responses;
}
</code></pre>
<p>It first validates the expected ids. It then rejects non-object rows, a wrong <code>jsonrpc</code> version, a missing row id, an unknown id, a duplicate id, and a result/error pair that is not exactly one present. Finally, it checks that every expected id was seen. The returned <code>Map</code> keeps the original response object for each id, so a result or error is not rewritten.</p>
<h2>Consume successes, fail on errors</h2>
<p>The snippet is application pseudocode. <code>validateForMethod</code> and <code>consume</code> are application-provided; only the correlator above is a complete, copyable module.</p>
<pre><code class="language-js">import { correlateBatch } from "./batch-response.mjs";

const byId = correlateBatch(requests, payload);

const values = requests.map((request) =&gt; {
  const response = byId.get(request.id);
  if (Object.hasOwn(response, "error")) {
    throw new Error(`RPC ${response.error.code}: ${response.error.message}`);
  }
  return validateForMethod(request.method, response.result);
});

for (const value of values) {
  consume(value);
}
</code></pre>
<p>Callers should iterate over the requests they sent, look up each response by id, and handle the two branches explicitly. Error entries remain errors. This helper does not turn an RPC error into a successful value, retry it, or advance a checkpoint. The <code>map</code> step validates the whole batch before any <code>consume</code> call, so later errors block earlier consumptions. This is application ordering, not a database transaction or all-or-nothing guarantee; persistence and checkpoint advancement stay with the application. A job that needs retries should make that decision outside the correlator and should only advance a checkpoint after every required call has a validated success.</p>
<p>Do not skip the method-specific step. A syntactically valid <code>result</code> can still be the wrong shape for <code>eth_getLogs</code>, <code>eth_getBalance</code>, or any other method. Envelope validation is not result validation.</p>
<h2>Scope and limits</h2>
<p>This example is deliberately stricter than the full protocol. It expects non-empty read-only requests whose ids are unique positive safe integers. It does not cover notifications, null ids, string ids, or fractional ids. Those are constraints chosen for this example, not claims that JSON-RPC forbids other id forms. The function checks envelope, id, and error shape only. It does not validate method result semantics, chain or finality, checkpoints, or the whole protocol.</p>
<p>Batch size and concurrency are different budgets. Batch size is how many calls go into one request; concurrency is how many requests are in flight. Lowering one does not automatically lower the other. Joining responses by id fixes correlation; it does not remove a provider's batch limit or concurrency limit.</p>
<p>If you are querying logs over a block range, the BlockVectra getLogs guide is a natural next step: <a href="https://docs.blockvectra.com/en/guides/getlogs-block-range/?ref=hashnode-jsonrpc-batch-ids-1021">Get logs over a block range</a>.</p>
<p>By BlockVectra, with AI-assisted drafting. The code was run locally with Node's built-in test runner; independent review is not claimed.</p>
<p>Runnable MIT companion and 14 offline checks: <a href="https://gist.github.com/gjwang/bf08843780c46ffabc4bb628db11a394">code and tests</a>. Download both <code>.mjs</code> files and run <code>node --test batch-response.test.mjs</code>.</p>
]]></content:encoded></item><item><title><![CDATA[Wallet balance APIs: unknown token decimals are not zero]]></title><description><![CDATA[Preserve raw units, paginate the response envelope, and distinguish indexed head from balance snapshot time.]]></description><link>https://blockvectra.hashnode.dev/wallet-balance-api-null-decimals-pagination-snapshots</link><guid isPermaLink="true">https://blockvectra.hashnode.dev/wallet-balance-api-null-decimals-pagination-snapshots</guid><category><![CDATA[Ethereum]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Web3]]></category><dc:creator><![CDATA[BlockVectra Engineering]]></dc:creator><pubDate>Tue, 20 Oct 2026 06:16:16 GMT</pubDate><content:encoded><![CDATA[<p><em>Prepared by the BlockVectra AI assistant. This is a contract-based integration note with an offline JavaScript reproduction; it does not report a live wallet integration.</em></p>
<p>A wallet can receive HTTP 200 and still display the wrong portfolio. Two particularly quiet mistakes are treating unknown token decimals as zero and feeding a paginated response object into a parser that expects an array.</p>
<h2>Unknown decimals are different from zero</h2>
<p>In BlockVectra’s documented address-balance schema, <code>balance</code> is a decimal integer <strong>string</strong>. <code>decimals</code> is an integer from 0 through 255, or <code>null</code>; <code>symbol</code> can also be <code>null</code>.</p>
<p>Zero decimals is a known scale. Null means the scale is unknown. JavaScript coercion erases that distinction:</p>
<pre><code class="language-javascript">import assert from 'node:assert/strict';

assert.equal(Number(null), 0);
assert.equal(Number('9007199254740993').toString(), '9007199254740992');

const row = { balance: '9007199254740993', decimals: null, symbol: null };
assert.equal(BigInt(row.balance), 9007199254740993n);
assert.equal(row.decimals, null);
assert.notEqual(row.decimals, 0);
</code></pre>
<p>This is a synthetic row excerpt, not a complete API response. The assertions reproduce coercion and integer precision loss locally; they make no network requests. Preserve the raw balance string. Use an exact integer/decimal representation for calculations, and leave the scaled amount unknown when decimals is null. Do not invent a symbol or infer a zero balance from missing metadata. BigInt helps with integer arithmetic; convert it back to a string before JSON serialization.</p>
<h2>An envelope is not a token array</h2>
<p>The documented response to <code>GET /v1/data/{chain}/addresses/{address}/balances</code> has <code>data</code>, <code>meta</code>, and an optional <code>next_cursor</code>. Only <code>data</code> is the token array. A bare-array parser that silently returns <code>[]</code> for any object can turn an integration error into a convincing empty wallet.</p>
<p>Handle HTTP and response-shape failures separately from a valid empty <code>data</code> array. Follow the opaque <code>next_cursor</code> unchanged until it is absent. Preserve failures on later pages too: a partial first page is not a complete portfolio. Keep <code>null</code> distinct from absence where the contract makes that distinction; the final page omits <code>next_cursor</code>.</p>
<h2>Indexed head is not snapshot time</h2>
<p>For balances, <code>meta.as_of_block</code> describes the current indexed head. It does not give the balance snapshot’s height or update time. Read <code>meta.refreshed_at</code> for the snapshot update time; null means stale or unknown update time. A coverage label is not a freshness guarantee.</p>
<p>Before choosing this endpoint for a chain, check that chain’s <code>data_features</code> and <code>data_status</code> in <code>GET /v1/status</code>. Unsupported coverage returns 422 <code>no_coverage</code>. This article does not establish current coverage for any particular chain.</p>
<h2>Adapter cases worth retaining</h2>
<p>Test an explicitly empty successful page, an HTTP/HTML error, an unexpected response shape, several cursor pages, a failed later page, a balance above the safe integer range, null decimals versus zero decimals, and null snapshot time. These are suggested integration cases, not completed end-to-end tests.</p>
<p>The <a href="https://docs.blockvectra.com/en/guides/wallet-assets/?ref=hashnode-wallet-balance-adapter">wallet assets guide</a> covers the endpoint and snapshot semantics. The <a href="https://docs.blockvectra.com/openapi/data.yaml?ref=hashnode-wallet-balance-adapter">public Data API schema</a> defines <code>UInt256String</code>, <code>AddressBalance</code>, <code>AddressBalanceListEnvelope</code>, and <code>Meta</code>. The contract snapshot used here was saved on 2026-10-20; verify the current contract before implementing an adapter.</p>
]]></content:encoded></item><item><title><![CDATA[Why an eth_getLogs backfill can skip its last block]]></title><description><![CDATA[A reproducible boundary bug, four offline checks, and the checkpoint contract a retry needs.]]></description><link>https://blockvectra.hashnode.dev/eth-getlogs-backfill-inclusive-range-checkpoints</link><guid isPermaLink="true">https://blockvectra.hashnode.dev/eth-getlogs-backfill-inclusive-range-checkpoints</guid><category><![CDATA[Ethereum]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Web3]]></category><dc:creator><![CDATA[BlockVectra Engineering]]></dc:creator><pubDate>Mon, 19 Oct 2026 21:35:01 GMT</pubDate><content:encoded><![CDATA[<p>A one-block request can disappear before it ever reaches an Ethereum RPC endpoint. The problem is easy to miss when testing large, evenly sized backfills: a client can confuse the distance between block numbers with the number of blocks to scan.</p>
<p>Here is the arithmetic: the inclusive range 42..42 contains one block, although 42 minus 42 is zero. For 10..110, the distance is 100 but the range contains 101 blocks.</p>
<h2>The failure is in range planning</h2>
<p>While investigating ERC-20 history queries in Bitcore, we reproduced a loop that skipped a single-block range and certain final one-block remainders. The proposed fix changes the loop condition from <code>windowSize &gt; 0n</code> to <code>windowSize &gt;= 0n</code> in that implementation. This is a client-side omission: the missing request never reaches a provider.</p>
<p>The <a href="https://github.com/bitpay/bitcore/pull/4342">proposed patch and seven provider-level regressions</a> are public. This article describes the patch at commit <code>c42c8a7a67899a3f955080faddd0b2de54bbcc4a</code>; a contribution is not a claim that an upstream release includes it or that its maintainers endorse BlockVectra.</p>
<p>Do not copy that condition change into every loop. Its correctness depends on what the variable means. If a variable is a count of remaining blocks, zero means finished. If it is the distance between inclusive endpoints, zero still means one block.</p>
<h2>A small, executable boundary check</h2>
<p>This separate range planner makes the inclusive endpoints explicit. Save it as <code>inclusive-ranges.mjs</code> and run <code>node inclusive-ranges.mjs</code>. It needs no package install, account, API key or network connection. Inputs in this example are BigInts; 50 is an illustrative chunk size, not a provider limit.</p>
<pre><code class="language-js">import assert from "node:assert/strict";

function ranges(from, through, maxBlocks) {
  if (from &lt; 0n || through &lt; 0n || maxBlocks &lt;= 0n) {
    throw new RangeError("Nonnegative bounds and positive size required");
  }
  const result = [];
  while (from &lt;= through) {
    const candidate = from + maxBlocks - 1n;
    const end = candidate &lt; through ? candidate : through;
    result.push([from, end]);
    from = end + 1n;
  }
  return result;
}

assert.deepEqual(ranges(42n, 42n, 50n), [[42n, 42n]]);
assert.deepEqual(ranges(10n, 110n, 50n), [
  [10n, 59n], [60n, 109n], [110n, 110n],
]);
assert.deepEqual(ranges(10n, 109n, 50n), [
  [10n, 59n], [60n, 109n],
]);
assert.throws(() =&gt; ranges(0n, 1n, 0n), RangeError);
console.log("Four offline range checks passed");
</code></pre>
<p>The cases deliberately include one block, a final one-block remainder, an exact boundary, and a zero-size rejection. Test the query parameters and returned records at the real provider boundary too: a separate planner passing checks does not prove a production caller uses it correctly. This planner returns no ranges when the start is already beyond the upper bound; do not send reversed bounds to an RPC method.</p>
<h2>A correct range still needs a durable checkpoint</h2>
<p>Once the range exists, fetching and committing are separate operations. Use a fixed upper-bound snapshot for one backfill pass, read a chunk, validate the response shape, then durably store its logs and checkpoint together. Advance the in-memory start only after that commit succeeds.</p>
<p>For example, after committing through block 109, the next start is 110. If the request for block 110 fails, or its database transaction fails, the durable checkpoint must remain at 109. A restart should retry 110. Conversely, a successful empty log array can be committed for a completed range; an empty result is not automatically an error.</p>
<p>A crash between writing logs and writing the checkpoint can otherwise create duplicate work or skipped work. Implement atomic persistence in your own database adapter and make retried writes idempotent. Scope the checkpoint to the chain, contract/filter and decoder or schema version.</p>
<p>Our <a href="https://gist.github.com/gjwang/5a07d888ff5e89e1c99db2564b8c8d7a">original MIT-licensed backfill example</a> includes an injected-client helper and nine previously run offline mock tests for chunk boundaries, failure/resume, malformed responses and consumer failure. It delegates atomic persistence to the caller; it is not a database adapter or a released production SDK.</p>
<h2>Three checks that remain outside the loop</h2>
<p>First, an array-shaped response is not proof of completeness. A provider can return a syntactically valid array that omits matching logs. Range arithmetic and shape checks do not establish that every canonical event was returned.</p>
<p>Second, a head snapshot is an upper bound, not a finality guarantee. Verify the target chain before use, retain the information your canonicality checks need, and implement an explicit finality and reorg policy.</p>
<p>Third, distinguish request size from shared capacity. A narrower range may help an oversized query. It does not by itself fix a saturated shared budget. Bound concurrent requests and retries with a real overall deadline; a single-block failure cannot be fixed by splitting the block range forever.</p>
<p>The useful acceptance test is concrete: can a one-block range be requested, can its final result be committed, and can a crash resume from the last durable checkpoint without inventing progress?</p>
<p>Prepared by BlockVectra's AI assistant from source inspection and offline checks. The code example above was run locally; no production RPC was called for this article. BlockVectra builds RPC and Agent tooling; its <a href="https://github.com/blockvectra/mcp">public MCP connection repository</a> is separate from this injected-client example. Related documentation: <a href="https://docs.blockvectra.com/en/guides/getlogs-block-range/?ref=hashnode-inclusive-backfill">RPC log-range limits</a>. JSON-RPC method reference: <a href="https://ethereum.org/developers/docs/apis/json-rpc/#eth_getlogs">eth_getLogs</a>.</p>
]]></content:encoded></item></channel></rss>