FORENSIAWeb3 forensicsOpen workspace

Comparing contract code before, after and at block end

Forensia lines up the code a source reported for a call target at up to three moments around a transaction. It does not establish which bytes any individual call actually ran.

· Forensia · 7 min read · Transactions, Evidence

A contract address is not a fixed program. The code stored at an address can be created, replaced through a pointer, or removed, and that can happen inside the same block as the transaction you are reading. So the question "what code was at this address" always needs a second half: at what moment.

Forensia now puts the available answers side by side. For up to four of the addresses a transaction called, it shows the code a source reported at the start of the transaction, at the end of the transaction, and at the end of the block that contains it, and says whether those observations match.

The limit comes first. These are three snapshots taken at boundaries. They do not tell you which bytes ran during any one internal call, and a match between them does not show that the code stayed the same in between.

#Plain languageThree photographs of the same door

Think of a shop door photographed three times: when a delivery van arrives, when it leaves, and when the street closes for the night. If the sign on the door reads the same in all three photographs, you have learned something. You have not learned what it said in between, and you have not watched the delivery itself.

The comparison works the same way. Each observation is a size in bytes and a fingerprint (a hash) of the code a provider returned for one address at one boundary. Forensia then gives one of three plain results for each address:

  • The recorded boundaries match: every available observation has the same fingerprint.
  • Different code at recorded boundaries: at least two observations differ. That does not say when or why the code changed, or whether the change was malicious.
  • Not enough recorded code to compare: fewer than two usable observations exist. Missing information does not mean the address had no code.

Three kinds of content are kept apart: ordinary contract code, an explicitly empty answer, and a delegation pointer (a short marker that says "use the code at this other address"). A pointer is shown as a pointer. Forensia does not follow it.

#How it worksWhere to find the comparison in the workspace

  1. Look up an Ethereum transaction and stay on Overview.
  2. Open "Inspect transaction steps", then open "Compare code observations". Opening it makes no new requests; it reads what the capture already holds.
  3. Read the count line. It says how many requested targets are shown and how many additional targets were not compared.
  4. Expand a target. You see three labeled cells: Transaction start, Transaction end and Containing-block end. Each one shows a byte count, "Explicitly empty code", "Delegation pointer · destination unresolved" or "Not available".
  5. Open "Exact hashes, account context and sources" for the Keccak-256 hash of each observation, the basis for it, and buttons that open the exact source record, such as "Inspect state trace" or "Inspect block-end code source".

If the optional transaction-start read was skipped or failed, a sentence above the list says why in plain terms: for example, that it was skipped to stay within the request limit, or that the response exceeded the size limit and was not retained. Older captures that carry no reason get no invented explanation.

A separate check lives under Sources, in "Capture integrity & local evidence". It compares the displayed block-end code with the saved response bytes in your browser, without fetching anything. It tests whether the display agrees with what was saved, not whether the provider was right.

#Technical detailWhat is read, what is compared, and when it refuses

Which addresses are selected

Targets come from the recorded call tree. A step qualifies only when its call context is interpreted consistently, with a known target and a known account. Targets are lowercased and de-duplicated, and the first four are kept; the limit of four targets and 49,152 code bytes per value is a constant in the code. Each call ID that requested the target is kept, with the account whose storage that call ran against.

That last point matters for DELEGATECALL. A delegated call runs the target’s code against the caller’s storage and keeps the caller’s msg.sender and msg.value. The comparison is always about the requested target address. It does not substitute the code of the storage account.

The three sources

BoundaryWhere the value comes fromRequest
Transaction startThe code field on the pre side of the state diff. If that is absent, the optional full-prestate read.debug_traceTransaction with prestateTracer, diffMode true; optionally a second call with diffMode false and storage disabled
Transaction endThe code field on the post side of the state diff, or the unchanged-field rule described below.The same diffMode true response
Containing-block endThe code returned for the address at the block identified by hash.eth_getCode with a blockHash and requireCanonical true

The state sequence is Ethereum only and runs on one pinned endpoint: eth_chainId, eth_getBlockByNumber, eth_getTransactionReceipt, the diff trace, the optional start-code trace, then eth_getBlockByNumber again to recheck that the transaction still sits at the same index in the same block. Fallback to another endpoint is allowed only on the first request. The tracer options are fixed:

State diff, then optional transaction-start code
{ tracer: "prestateTracer", timeout: "3s", tracerConfig: { diffMode: true,  disableCode: false, disableStorage: false } }
{ tracer: "prestateTracer", timeout: "3s", tracerConfig: { diffMode: false, disableCode: false, disableStorage: true  } }

Budgets that are literally in the code

  • The whole state sequence has a 15,000 ms deadline and at most six requests.
  • The diff response is capped at 1,048,576 bytes, 64 accounts, 256 storage slots and 262,144 bytes of code in total.
  • The optional start-code response is capped at 2,097,152 bytes and 256 account records, and only the selected targets’ code is retained.
  • The optional read is skipped when every selected target already has explicit pre-code in the diff, or when fewer than 7,500 ms or two upstream requests remain, so the final block recheck can still run.

The unchanged-field rule

Geth’s diff mode omits fields that did not change. Forensia applies that rule to code in exactly one situation: the account appears on both the pre and post sides, and the pre side carries an explicit code field while the post side does not. The transaction-end observation is then labeled with the basis "Unchanged post-code omission in Geth diff" instead of "Explicit code field", so you can see it was inferred from the tracer’s rules and not returned as its own value. Creation, deletion, an absent account or a missing pre-code field never produce an empty-code observation.

Classification and comparison

Each observation is hashed with Keccak-256 and its length counted in bytes. The value 0x is "empty". A value of the form 0xef0100 followed by 20 bytes is an EIP-7702 delegation pointer, and the 20 bytes are shown as an unresolved destination. Everything else is bytecode. With two or more observations, one distinct hash means a match and more than one means a difference.

Refusals

  • State observations are ignored unless the saved network, transaction hash, block number, block hash and source references are consistent, and the recorded outcome of the root call equals the receipt status in the state record.
  • The block-end observation is ignored unless its request names the same address and block hash with requireCanonical set, and resolves to exactly one captured eth_getCode record.
  • If explicit pre-code in the diff disagrees with the optional start-code read, the whole state observation is rejected as a conflict.
  • A response over a limit yields no partial result.

In one reviewed Ethereum example, 0x22fc4a1bba0e2a711a5852f5c835eea4cb764f989895b1b48ec73d84dab8ed95, four targets were shown and nine further targets were not compared. One target reported 19,572 bytes with the same hash at all three boundaries.

#LimitsWhat a match or a mismatch does not establish

  • It does not establish the code executed by any individual call. Code can change between the start of a transaction and a nested call, and change back.
  • Matching hashes do not show the code was unchanged between the observed moments.
  • A difference is not a source contradiction, and it is not evidence of a malicious upgrade. The three boundaries are different moments.
  • Delegation pointers are not resolved. Pointer consistency says nothing about what the destination contained or whether it ran.
  • The values are provider-reported. Forensia does not re-execute the transaction, and consistency checks on saved metadata do not authenticate it.
  • Nothing here is verified source code or a statement about who controls a contract.
  • Only four targets are compared. Transaction-start and transaction-end observations exist for Ethereum only.