What code was at an address when the transaction ran
For up to four addresses a transaction called, Forensia records the code a provider reported at three moments around it. It does not prove which bytes any single call executed.
When you read an old transaction, it is natural to look up the contract it called and read what that contract does. The catch is that you are reading the contract as it is today. Code at an address can change: contracts get created and destroyed, and a wallet can point itself at different code over time.
Forensia now records what code a provider reported at the addresses a transaction called, at moments close to the transaction itself, and shows those observations side by side. It tells you plainly when an address had no code, when it held only a pointer to other code, and when the provider gave no usable answer.
These are observations at fixed moments. They narrow the question. They do not settle which code actually ran inside a particular call.
#Plain languageThree photographs, not a film
Imagine checking who occupied an office on a particular day. You have a photograph of the door sign taken that morning, one taken that evening, and one from the end of the week. If all three show the same name, that is useful. It still does not show who was in the room at 2 pm, and it cannot rule out a sign that was swapped and swapped back.
Forensia collects up to three such photographs of an address’s code: at the start of the transaction, at the end of the transaction, and at the end of the block that contains it. It compares the ones it has and reports one of three things: they match, they differ, or there are not enough of them to compare.
Three kinds of answer are kept apart, because they mean different things. Empty code is an explicit answer that nothing was there. A delegation pointer is a short marker saying this account borrows behavior from another address. No answer at all is unknown, and it is never treated as empty.
#How it worksOpening the comparison in the transaction steps
- Look up a transaction hash. On Overview, open "Inspect transaction steps".
- Open the disclosure "Compare code observations". Opening it makes no new requests.
- Read the line that says how many requested targets are shown and how many additional targets were not compared. At most four are shown.
- Open a target. Its heading reads "recorded boundaries match" with a count, "Different code at recorded boundaries" or "Not enough recorded code to compare".
- Read the three columns: Transaction start, Transaction end and Containing-block end. Each shows a byte count, "Explicitly empty code", "Delegation pointer · destination unresolved" or "Not available".
- 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.
If the optional start-of-transaction read did not succeed, a sentence above the targets says why in plain words, for example that the response exceeded the size limit or that the check was skipped to leave time for the final block check. Below the comparison, "Original block-end code observations" keeps the simpler list of block-end results, which is what older saved captures contain.
The review transaction 0x22fc4a1bba0e2a711a5852f5c835eea4cb764f989895b1b48ec73d84dab8ed95 is a good place to look. In one recorded capture it showed four targets with nine more not compared. Two targets matched at three boundaries and two had too few observations. A later capture of the same transaction returned more. Results depend on what the provider supplies at the time.
#Technical detailWhere each observation comes from
Choosing the addresses
Targets are taken from the captured call records: the destination of every frame whose call type is CALL, STATICCALL, DELEGATECALL or CALLCODE. Token events from the receipt are not call targets. Contract creation frames are excluded, because creation runs initialization code and the code that ends up deployed is a different thing. Addresses are de-duplicated in order of appearance and the first four are used.
The comparison view adds a second condition: the frame must have an interpreted account context. For a DELEGATECALL the requested target and the account whose storage is used are different addresses. The comparison is always about the requested target, and the storage account is listed separately so the two are not confused.
Containing-block end
One eth_getCode request per target, addressed by block hash instead of a block number or a tag:
{ "jsonrpc": "2.0", "method": "eth_getCode",
"params": [ "<target address>", { "blockHash": "<containing block hash>", "requireCanonical": true } ] }Pinning the hash means the answer cannot silently come from a different fork of the chain. Each request has a 2.5 second limit. A result is accepted only if it is well-formed hex of at most 49,152 bytes. Anything else, including a refusal or a timeout, is stored as unavailable with no code value.
Transaction start and Transaction end
These two come from the Ethereum-only state trace, a debug_traceTransaction call using the prestateTracer in diff mode. That response lists accounts the transaction changed, with their values before and after. The code reads it as follows.
| Boundary | Source | Basis shown |
|---|---|---|
| Transaction start | The code field on the before side of the diff | Explicit code field |
| Transaction start (fallback) | One optional second trace, full prestate with storage disabled | Explicit code field |
| Transaction end | The code field on the after side of the diff | Explicit code field |
| Transaction end (inferred) | Account present on both sides, before-code explicit, after-code omitted | Unchanged post-code omission in Geth diff |
| Containing-block end | The eth_getCode response above | Explicit code field |
The inferred row follows the tracer’s documented rule that an unchanged field is left out of the after side. It applies only when the account appears on both sides and its before-code is explicit. Created accounts, deleted accounts, absent accounts and absent fields never produce a code value.
The optional second trace exists because a contract that was called but not changed does not appear in a diff. It runs at most once, only when one of the four targets lacks explicit before-code, and it uses the same pinned endpoint with no mid-sequence fallback. Its limits in the code: 2 MiB response, 256 account records, code kept for the four selected targets only, 49,152 bytes per code value. It is skipped unless at least two upstream requests and 7.5 seconds remain, so the mandatory final block recheck can still run. If an explicit before-code in this response disagrees with the diff, the whole state observation is rejected.
When it does not succeed, one of seven reasons is recorded: request-budget, time-budget, timeout, response-limit, source-unavailable, invalid-response or interpretation-limit. Provider error text is never copied into the message.
Classifying and comparing
Each code value is classified as empty (exactly 0x), a delegation pointer (the EIP-7702 designator: the bytes ef 01 00 followed by a 20-byte address), or bytecode. Its length and Keccak-256 hash are computed. A target with fewer than two observations is not comparable. With two or three, the comparison is simply whether the set of hashes has one member or more than one.
Before any observation is used, its metadata is validated: the state trace must match the network, transaction hash, block and root execution status, and each eth_getCode record must be the single record with that ID, with the right address, block hash and requireCanonical set to true. The comparison is derived on display and never written back into the capture.
Rechecking saved code
Under Sources, Capture integrity & local evidence, Check local evidence compares each displayed block-end code value with the saved response body, up to 128 KiB per response. Hex letter case is ignored. Missing or expired bodies leave the check incomplete, and a contradiction in any available body is reported even when another body is missing.
#LimitsWhat these observations cannot tell you
- They do not prove which bytes an individual call executed. Code can change inside a transaction, and between a transaction and the end of its block.
- Matching hashes do not rule out a change and a change back between the recorded moments.
- Different hashes do not establish when or why code changed, or that a change was malicious.
- A delegation pointer is shown with its destination, but the destination’s code is not fetched or analyzed. A pointer is not a finding about safety or ownership.
- Empty code at block end does not rule out a precompile, earlier execution or a change within the same block.
- Not available means unknown. It is never shown as empty.
- Only the first four requested targets are examined. This is not a code inventory for the transaction.
- Code is not matched to verified source by this view, and it is not evidence that a given write was caused by a given instruction.
- Transaction start and Transaction end depend on a state trace that is supported for Ethereum only and that providers do not always serve.
- The saved-code check confirms consistency with a provider response. The response itself is not an independent proof of chain state.