FORENSIAWeb3 forensicsOpen workspace

Connecting a token approval to the transfer that followed

Forensia sets a recorded approval beside a later transfer and, where one exact transferFrom call matches, links to it. It never states that the approval caused the transfer.

· Forensia · 7 min read · Permissions, Evidence

Many token losses start with a permission, not a payment. A holder approves another address to move their tokens, and some time later tokens leave. The two records sit in different places, and it is tempting to draw a line between them and call it the cause.

Forensia now puts the two records side by side and checks whether the captured call records contain the specific spending call that would connect them. When exactly one call fits, you can open it. When none fits, or more than one fits, the workspace says that instead.

The wording stays cautious on purpose. An approval followed by a transfer is an order of events. It is not proof that this permission was the one used.

#Plain languageA permission is a standing instruction, not a payment

Picture a signed note you leave with a bank: this named person may withdraw up to a set amount from my account. Writing the note moves no money. A later withdrawal might rely on the note, or on a different note, or on something else entirely. To connect them you would want to see the withdrawal slip itself, naming the same account, the same person and the same amount.

On a token contract, the note is an approval. Cancelling it is a revocation. The withdrawal slip is a call named transferFrom, where the approved address (the operator) asks the token to move the holder’s tokens to a recipient.

Forensia reads approvals and revocations from the recorded events, lines them up in order with the transfers that followed, and then looks for the matching slip in the recorded calls. It reports what it found in one of a small number of fixed outcomes.

#How it worksFinding the comparisons on the Analysis view

  1. Look up a transaction hash or a wallet address on a supported EVM network.
  2. Open Analysis and go to the stop titled "Permissions and later transfers". It appears only when the lookup produced at least one comparison.
  3. Read a row, headed "Permission comparison" with a number. It names the Token contract, Token holder, Approved operator and Transfer recipient separately, with the Recorded amount and the Event positions (log index of the approval, then of the transfer).
  4. Open the disclosure under the row. Its title is the outcome, for example "Matching call returned true" or "Approval is not proof of spending".
  5. If a button beginning "Inspect matching call" is present (it also names the step number), select it to jump to that exact step under Inspect transaction steps. From there you can open its Execution trace source.
  6. Use "Back to permission comparison" to return. The row reopens and keeps keyboard focus.

Four comparisons are shown first. Any others sit under one disclosure whose label says how many more there are. The review example in the local guide for this feature is the Ethereum transaction 0x698dc47038978fad61d69a87d14b98679e37a772fa06d726523778d1e34530a7, which in the recorded check held four permission events and three comparisons: two with a matching call that returned true, and one zero-quantity comparison that stayed "Approval is not proof of spending" with no button.

#Technical detailHow events are ordered and a spending call is matched

Decoding permission events

Permission events are decoded from receipt logs by topic 0 and shape. The owner is topic 1 and the operator is topic 2.

EventShape requiredRead asGranted when
Approval(address,address,uint256)3 topics, 32-byte dataERC-20 allowanceThe amount is greater than zero
Approval(address,address,uint256)4 topics, empty dataERC-721 approval for one token IDThe approved address is not the zero address
ApprovalForAll(address,address,bool)3 topics, data of 0 or 1ERC-721 or ERC-1155 operator approvalThe value is 1

Anything else is not decoded. A shape is not proof that the emitting contract follows the standard.

Building a sequence

For a transaction lookup, events come from the single eth_getTransactionReceipt response and are sorted by log index. For each grant, the code finds the next event with the same scope: same contract, same owner, same kind, and the same operator (or the same token ID for a single-token approval). That next event is a boundary, whether it replaces or revokes the grant. A transfer joins the grant only if its log index is after the grant, before the boundary, it comes from the same contract, its sender is the owner, and its token standard is compatible.

  • Two permission events claiming the same log index are contradictory. Neither is used.
  • The boundary search uses every decoded permission event in the receipt, not only the ones kept for display. A revocation that is not displayed still ends the grant.
  • A single-token ERC-721 approval is paired with its first compatible transfer only, because that transfer clears it.
  • The view keeps at most 48 permission events and 24 sequences. The full receipt remains in Sources.

For a wallet lookup, approvals come from two eth_getLogs requests over the lookup’s bounded block window, filtered by the Approval and ApprovalForAll topics with the subject as indexed owner. The newest 48 are kept. Ordering uses block number first, then log index. These rows read "Approval before a later transaction" and stop at ordering: the call check below does not run on them.

Looking for the transferFrom call

The call check covers one case only: an ERC-20 approval and outflow in the same captured transaction. It needs a usable call hierarchy from debug_traceTransaction with the callTracer, and on current captures an Ethereum mainnet rule context with matching transaction and receipt types from 0 to 4. Outside that, the outcome is "Spending call not established" and the captured calls and events remain as they are. A candidate frame must satisfy every row below.

FieldRequirement
Origin and call typeFrom the execution trace, call type CALL (not DELEGATECALL, STATICCALL or CALLCODE)
StatusSuccess, with no failed or unverified ancestor
Call valueZero
Selector0x23b872dd, followed by exactly three 32-byte words
ArgumentsOwner, recipient and amount equal the sequence exactly
CallerEquals the approved operator and is not the owner
TargetThe token contract, interpreted as running in its own account context and not read-only
SourcesEvery source ID resolves to exactly one record scoped to this transaction, and at least one is a captured, matching trace

Before any of that, the approval must be the only record at its transaction and log position, the transfer amount must not exceed the approved amount, no later permission update for the same holder, operator and token may sit between the two events, and exactly one successful receipt transfer must exist at the stated log index with its receipt source.

The outcomes

StateTitle shownWhen
matching-callMatching call returned trueOne candidate; return data is 31 zero bytes then 01
matching-callMatching arguments; token result unknownOne candidate; return data is empty, missing or non-standard
return-falseMatching call returned falseOne candidate; return data is 32 zero bytes
ambiguousMore than one call matchesTwo or more candidates; none is selected
not-observedApproval is not proof of spendingNo candidate in the retained calls
not-assessedSpending call not establishedOut of scope, missing records or conflicting records

The button target is recomputed from the retained sequence each time. It is never taken from an ID supplied by the page. No button is offered for ambiguous, absent or out-of-scope results. A call that returned false stays inspectable, with its qualification.

Rechecking against the saved receipt

Under Sources, Capture integrity & local evidence, Check local evidence reinterprets the saved receipt and compares every retained event and sequence with it. Limits in the code: 8 MiB of receipt, 16,384 logs, 4,096 decoded transfer items, 48 events and 24 sequences. It reports matched, conflict, unavailable or check-limit. The receipt’s transaction and block identity are checked before any size limit, so an oversized log list cannot hide a wrong receipt. This check is receipt-local and does not cover wallet-history comparisons.

#LimitsWhat a matching call still leaves open

  • It does not prove the allowance was consumed, or that this approval caused this transfer.
  • The call tracer does not bind receipt logs to call frames. A matching call and a matching event are two records with the same values, not a proven pair.
  • A returned true is a recorded return value. It is not proof of final balances or of allowance state. Empty or non-standard returns are not assumed successful.
  • It does not show the current allowance.
  • NFT approvals are ordered but not assessed by the call check. A token ID is never read as a spendable quantity.
  • "Approval is not proof of spending" does not mean no spending happened. Calls can be missing from bounded or older captures.
  • An empty Permissions stop does not mean no permissions exist. The view is a bounded sample.
  • Permit signatures, token code that behaves differently from the standard, owner identity and intent are outside this analysis.
  • The local check confirms consistency with a saved provider response. It is not independent execution.