Skip to content
Ethras
Menu
← All articles

Ethras Blog

Ask for Historical Capabilities, Not Just an Archive Node Label

Separate old blocks, receipts, state, proofs, traces, and orphan retention when choosing an Ethereum data source for an application.

By Ethras Team ·

An endpoint returns an old transaction receipt and its logs, but a storage read at the same block fails. Another endpoint returns the old storage value but rejects a historical proof request. Calling either result “not archive” loses the distinction your application actually needs: chain history, historical state values, and historical trie evidence are separate capabilities.

This article builds a qualification matrix around a contract whose value changes from seven to nine to zero. We read real historical data from a local Anvil, then apply explicit policy fixtures that hide state or proof access. The fixtures do not prune Anvil's database and are not measurements of public providers. They test whether a client preserves capability failures instead of converting them into false state.

Three retained objects answer different questions

Block history contains headers and bodies. Receipts contain execution outcomes and emitted logs. Historical state contains account and contract-storage values after a selected block. A historical proof additionally requires the trie evidence needed to verify those values against an appropriate state root. Retaining a value-oriented history representation does not automatically retain every historical trie node.

Client configuration makes these distinctions concrete. The Geth archive documentation, read for this article's research snapshot, distinguishes hash-based and path-based historical state. It also distinguishes flat-state history from historical trie-node retention. Its documented path-based proof support depends on client version and the relevant trie-history configuration. Those are Geth-specific implementation details, not properties implied by an RPC URL's marketing label.

Chain-history pruning is another axis. Geth's history-pruning documentation describes removing pre-Merge block bodies and receipts while preserving headers and the state trie. An event backfill can therefore lose a required history capability even when the node remains useful for validating and serving the current chain. Conversely, finding an old header does not establish that its receipts remain available.

This is why an archive requirement should name methods, anchors, and expected semantics. “Read storage slot zero at deployment block H” is testable. “Support old events from the application's first deployment through the current policy anchor” is testable. “Be archive” is too broad to serve as a pass condition and too vague to diagnose a failure.

Build an anchor-specific matrix

The laboratory checks six operations at the deployment anchor: block retrieval, receipt retrieval, hash-constrained logs, storage, a read-only contract call, and an account/storage proof response. The old state is known because the program compiled and deployed the contract itself. Later writes create different states, so an endpoint that ignores the historical selector cannot pass by returning today's value.

The three profiles are deliberately named full-lab, receipts-only, and state-no-proof. The first forwards all six reads to the same local client. The second forwards history reads and reports historical state unavailable. The third forwards state reads but reports the proof method unsupported. These profile outcomes are injected application-policy examples, not claims about how a particular pruning implementation behaves.

Each result keeps a kind such as value, unavailable, unsupported, missing, or error. The matrix does not store an exception as numeric zero. It also does not treat a returned proof-shaped object as cryptographically verified merely because its declared storage value matches the expected seven. Full proof verification against an independently trusted header is another capability and should have its own test.

EIP-1186 describes the account and storage proof response fields. Read the requested input representation carefully: this experiment uses a 32-byte storage key for proof retrieval. The final negative case sends the malformed odd-length 0x0 key to the tested client and confirms a parameter error. That failure is an input-format problem, not evidence of historical pruning.

Run the historical reads against known changing state

The experiment used Node.js 24.22.1, ethers 6.16.0, solc 0.8.33, and Anvil 1.5.1-stable with Cancun explicitly selected. Start a fresh local client below. Save the complete program as archive.cjs, then run node archive.cjs in another terminal. The program uses the unlocked test account and no real credentials.

npm install ethers@6.16.0 solc@0.8.33
anvil --host 127.0.0.1 --port 18544 --hardfork cancun --silent

The contract emits an event on deployment and on each later write. The script preserves the deployment block's number and hash, queries that historical state after two changes, and verifies its known value. The policy fixture lives inside qualified; every allowed operation still executes against the actual local client.

const assert=require('node:assert/strict'),solc=require('solc');
const {JsonRpcProvider,ContractFactory,Interface}=require('ethers');
const source='pragma solidity 0.8.33; contract History {uint256 public value=7; event Changed(uint256 value); constructor(){emit Changed(7);} function set(uint256 n) external {value=n;emit Changed(n);}}';
(async()=>{
 const p=new JsonRpcProvider('http://127.0.0.1:18544');
 try{
  const out=JSON.parse(solc.compile(JSON.stringify({language:'Solidity',sources:{'H.sol':{content:source}},
   settings:{evmVersion:'cancun',outputSelection:{'*':{'*':['abi','evm.bytecode.object']}}}})));
  assert.deepEqual((out.errors||[]).filter(e=>e.severity==='error'),[]);
  const c=out.contracts['H.sol'].History;
  const v=await new ContractFactory(c.abi,c.evm.bytecode.object,await p.getSigner()).deploy();
  const deployed=await v.deploymentTransaction().wait();await(await v.set(9)).wait();
  const zero=await(await v.set(0)).wait();
  const block=await p.send('eth_getBlockByHash',[deployed.blockHash,false]);
  const address=await v.getAddress(),data=new Interface(c.abi).encodeFunctionData('value');
  const requests={
   block:['eth_getBlockByNumber',[block.number,false]],
   receipt:['eth_getTransactionReceipt',[deployed.hash]],
   logs:['eth_getLogs',[{blockHash:block.hash,address}]],
   state:['eth_getStorageAt',[address,'0x0',block.number]],
   call:['eth_call',[{to:address,data},block.number]],
   proof:['eth_getProof',[address,['0x'+'00'.repeat(32)],block.number]]
  };
  async function qualified(profile,name){
   const[method,params]=requests[name];
   // Explicit policy fixture: forwards to one Anvil, does not prune any database.
   if(profile==='receipts-only'&&['state','call','proof'].includes(name))
    return {kind:'unavailable',reason:'fixture hides historical state'};
   if(profile==='state-no-proof'&&name==='proof')
    return {kind:'unsupported',reason:'fixture hides proof method'};
   try{
    const value=await p.send(method,params);
    if(value===null)return {kind:'missing'};
    if(name==='state'||name==='call')assert.equal(BigInt(value),7n);
    if(name==='block')assert.equal(value.hash,block.hash);
    if(name==='receipt')assert.equal(value.blockHash,block.hash);
    if(name==='logs'){assert.equal(value.length,1);assert.equal(value[0].blockHash,block.hash);}
    if(name==='proof'){assert.equal(BigInt(value.storageProof[0].value),7n);
     assert(value.accountProof.length>0);assert(value.storageProof[0].proof.length>0);}
    return {kind:'value',value};
   }catch(e){return {kind:'error',reason:JSON.stringify(e.error||{message:e.shortMessage||e.message})};}
  }
  const matrix={};for(const profile of['full-lab','receipts-only','state-no-proof']){
   matrix[profile]={};for(const name of Object.keys(requests)){
    const r=await qualified(profile,name);matrix[profile][name]=r.kind; if(r.kind==='error')console.log(profile,name,r.reason);
   }
  }
  assert(Object.values(matrix['full-lab']).every(x=>x==='value'));
  assert.equal(matrix['receipts-only'].receipt,'value');
  assert.equal(matrix['receipts-only'].state,'unavailable');
  assert.equal(matrix['state-no-proof'].state,'value');
  assert.equal(matrix['state-no-proof'].proof,'unsupported');
  await assert.rejects(p.send('eth_getProof',[address,['0x0'],block.number]),
   e=>e.error?.code===-32602); // Malformed key is not a retention failure.
  const latest=await p.send('eth_call',[{to:address,data},zero.blockNumber?'0x'+zero.blockNumber.toString(16):'latest']);
  assert.equal(BigInt(latest),0n);assert.equal(BigInt((await qualified('full-lab','call')).value),7n);
  assert.notEqual(matrix['receipts-only'].state,'value');
  console.log(JSON.stringify(matrix,null,2));
  console.log('old value=7 latest value=0; error remained unavailable, not zero');
  console.log('PASS actual historical block/receipt/log/state/call/proof; three explicit policy-fixture profiles');
 }finally{p.destroy();}
})().catch(e=>{console.error(e);process.exitCode=1;});

The executed matrix had six value cells for full-lab. Under receipts-only, block, receipt, and logs remained value, while state, call, and proof were unavailable. Under state-no-proof, the first five operations remained value and proof was unsupported. The final lines were:

old value=7 latest value=0; error remained unavailable, not zero
PASS actual historical block/receipt/log/state/call/proof; three explicit policy-fixture profiles

The word actual refers to the forwarded local Anvil reads. The two restricted profiles remain deliberate policy fixtures. No public-provider retention experiment, Geth pruning operation, or multiyear chain dataset was used to obtain this output.

Worked example: old receipts do not authorize old state

The deployment writes seven into slot zero and emits one Changed(7) log. A later transaction writes nine, and a final transaction writes zero. The receipt at the deployment hash remains a historical execution record. Its presence does not imply that every endpoint holding it can also execute value() against the deployment state.

In the receipts-only fixture, querying the old block, receipt, and log all succeeds and their block hashes are checked. The storage and call reads return unavailable. A caller that interprets those failures as zero would report today's value for an old state whose known value is seven, without ever having successfully queried that state.

The full laboratory path proves the distinction with actual execution data. eth_getStorageAt and eth_call at the deployment block both return seven, while the call at the final block returns zero. A fallback that silently changes the block selector could produce a syntactically successful response and still fail the application's historical question.

For a public qualification run, choose anchors where expected values changed. A contract whose storage happened to remain zero for years is a poor test for selector fidelity: returning zero from the wrong block could pass accidentally. Use known events, controlled deployments where available, or independently verified reference values to make the test discriminating.

Worked example: historical values do not authorize historical proofs

The state-no-proof fixture returns the old storage word and old call result correctly, then reports the proof operation unsupported. This is a valid separate outcome. An application that only needs a historical read may accept that profile, while an offline verifier requiring account and storage paths cannot.

The unrestricted local test requests slot zero using thirty-two zero bytes and receives account and storage proof arrays. It checks that both arrays are nonempty and that the returned storage value is seven. These checks establish usable response shape and consistency with the known fixture value. They deliberately do not claim that a nonempty array proves validity, canonicality, or independent header trust.

The malformed key test matters to diagnosis. The tested Anvil rejects odd-length 0x0 with -32602. Retrying that same malformed request on more archive-labelled endpoints would not improve the experiment. Correct the input first, then distinguish unsupported proof generation from unavailable historical nodes and from a proof that fails verification.

Where supported, EIP-1898 permits state queries pinned by hash. The storage and proof matrix here uses the recorded numeric deployment selector on a stable local branch. A public test near a reorganization should use supported hash selectors and validate ancestry, rather than assuming that a number remains the same state throughout a multi-request comparison.

Specify retention and recovery independently of the label

A useful qualification result records the network fingerprint, exact block number and hash, method parameters, client or endpoint revision where available, test time, and response class. Keep separate rows for the application's earliest required block, a changed-state historical anchor, and a recent confidence anchor. Success at one recent height says little about the oldest required range.

Trace access needs another row. It can depend on reconstructible prior state, block inputs, tracing implementation, permitted namespaces, and execution budgets. This experiment does not test traces, orphan-state retention, or a provider's maximum log range. Marking those cells untested is more honest than extending the six successful laboratory reads into an all-purpose archive claim.

Retention can also change after initial qualification. Keep enough raw events and headers for your own replay window, maintain an appropriate secondary history source, or explicitly accept that some older projections cannot be rebuilt. Test the recovery path before deleting the only local evidence. An endpoint label is useful background; an anchor-specific matrix with failure semantics is the contract your application can actually enforce.

Share this article

Share on X

Review your account and post on X. Opening X or copying text does not send a post.

Explore the RPC docs →