Revoke.cash Approved Transfers — Permit2 authority classes are collapsed into an incorrect forensic result
I reviewed the final report and the public Revoke.cash implementation behind the new Approved Transfers history feature.
This review is based on the public master branch available on 18 August 2026. If the production milestone is bound to a different commit, that exact commit should be published and evaluated against the same cases below.
The central issue is narrower and more serious than incomplete documentation:
The current classifier correctly traces many token movements, but it does not preserve the identity of the authority that caused them. In particular, Permit2 AllowanceTransfer and Permit2 SignatureTransfer can collapse into the same “Approved Transfer” result even though they represent different permission classes, different lifecycles and different revocation implications.
That directly affects the final report’s claims that:
- every outbound transfer is classified;
- every transfer is attributed to its spender;
- Permit2 attribution is correct;
- tracing is a foolproof way to determine the result.
Tracing is necessary, but a trace is not self-interpreting. The classification is only correct if the code reconstructs the complete authority route represented by that trace.
1. The report’s claimed invariant
The final report describes the delivered route as:
outbound transfer
→ debug_traceTransaction
→ exact call that moved the token
→ approved spender
→ correctly attributed Approved Transfer row
For that claim to hold, the system must distinguish at least:
direct owner transfer
standing token allowance
operator approval
Permit2 standing allowance
Permit2 one-time signature
EIP-3009 authorization
smart-account delegated execution
custom authorization route
unresolved route
These are not presentation variants of the same event.
They answer different questions:
- Who executed the token call?
- Who requested execution from the mediator?
- What authority allowed the movement?
- Was that authority persistent or single-use?
- What can still be revoked now?
- Which application or contract should be attributed?
- What evidence proves the classification?
The current result object does not preserve those dimensions.
2. What the current classifier stores
In:
packages/core/lib/transfers/trace-classifier.ts
the top-level classification is:
APPROVED
DIRECT
SIGNATURE_AUTHORIZED
OTHER
UNKNOWN
and the primary attribution fields are:
spenderAddress
permit2Address
selector
The comment on spenderAddress states that it represents either:
token-level msg.sender
or
the Permit2 caller of Permit2 AllowanceTransfer calls
That wording exposes the exact boundary: the special Permit2 attribution logic is limited to AllowanceTransfer.
The recognized Permit2 mediator selectors are only:
transferFrom(address,address,uint160,address)
transferFrom(AllowanceTransferDetails[])
Those are Permit2 AllowanceTransfer methods.
There is no corresponding authority reconstruction for:
permitTransferFrom(...)
permitWitnessTransferFrom(...)
including their single and batch variants.
3. Permit2 contains two different authority systems
The official Permit2 architecture explicitly consists of:
AllowanceTransfer
+
SignatureTransfer
They are materially different.
Permit2 AllowanceTransfer
owner grants Permit2 token allowance
→ owner grants a spender a Permit2 allowance
→ spender calls Permit2 transferFrom
→ Permit2 calls token transferFrom
The application has a standing, amount/time-bounded Permit2 allowance.
For this route, attributing the effective spender to the caller of Permit2 is correct.
Permit2 SignatureTransfer
owner has approved Permit2 at token level
→ owner signs a one-time Permit2 message
→ application calls permitTransferFrom / permitWitnessTransferFrom
→ Permit2 verifies signature and nonce
→ Permit2 calls token transferFrom
The application does not consume a standing Permit2 spender allowance.
The authority exists through a one-time signed message and is consumed during that transaction.
The official implementation verifies:
deadline
requested amount
unordered nonce
owner signature
and then performs:
safeTransferFrom(owner, destination, requestedAmount)
These two routes may produce a similar token-level child call, but they are not the same authority object.
4. The exact misclassification route
Consider this valid Permit2 SignatureTransfer call tree:
Application / router
→ Permit2.permitTransferFrom(...)
→ ERC20.transferFrom(owner, recipient, amount)
→ Transfer(owner, recipient, amount)
At the token boundary, the current classifier observes:
msg.sender = Permit2
selector = ERC20 transferFrom
Because transferFrom is in TRANSFER_FROM_SELECTORS, the result first becomes:
classification = APPROVED
spenderAddress = Permit2
The classifier then attempts Permit2 attribution by inspecting the parent boundary.
But findPermit2Attribution() accepts only the two AllowanceTransfer selectors.
The parent selector in this route is:
permitTransferFrom(...)
or:
permitWitnessTransferFrom(...)
It is therefore rejected by the AllowanceTransfer-only condition.
The override does not occur.
The resulting record remains:
classification = APPROVED
spenderAddress = Permit2
permit2Address = undefined
The actual application/caller is lost.
The one-time signature authority is lost.
The Permit2 mediator role is lost.
The history row is then rendered by the UI as:
Approved Transfer
Approved Spender: Permit2
That is not the correct causal description.
The correct description is closer to:
Signature-authorized Permit2 transfer
Requested by: <application / caller>
Mediator: Permit2
Authority: one-time signed permit
Persistent application allowance consumed: no
5. Why this is a serious correctness defect
This is not a cosmetic label problem.
Revoke.cash is a wallet-permission and incident-analysis product. The user relies on the history to understand:
- which actor moved funds;
- which authority made the movement possible;
- whether that authority remains active;
- what should be revoked;
- whether a drain resulted from a standing approval or a signed instruction.
The current collapse can produce a false narrative:
visible conclusion:
Permit2 was the approved spender that pulled the tokens
actual route:
an application submitted a one-time signature-authorized Permit2 transfer
Those narratives imply different remediation.
For a standing Permit2 allowance, the user may need to revoke the application’s Permit2 allowance.
For a consumed SignatureTransfer authorization, there may be no remaining application allowance to revoke. The relevant forensic object is instead:
signed message
nonce
deadline
witness/domain
caller
destination
transaction route
A history feature that merges those classes can direct attention to the mediator while hiding the application that exercised the signed authority.
That is an authority-provenance failure.
Severity:
High correctness / forensic-integrity defect
It is not, by itself, evidence that Revoke.cash enables theft. It is evidence that a security-facing feature can misstate how a real token movement was authorized and who should be attributed.
6. A second internal contradiction: signature support is gated by approval history
The classifier contains explicit handling for EIP-3009 signature-authorized transfers:
transferWithAuthorization
receiveWithAuthorization
and correctly assigns:
SIGNATURE_AUTHORIZED
However, before tracing, the indexer checks whether the owner/token pair has any historical:
Approval
or
ApprovalForAll
event.
If none exists, it records:
UNKNOWN
error = skipped: no approval events for owner and token
and does not trace the transaction.
That precondition is incompatible with the semantics of EIP-3009, whose purpose is to move tokens through a signed authorization without a standing allowance.
So the system currently contains:
a classifier for allowance-free signature transfers
behind a scheduling gate that requires evidence of an allowance
The recognition code exists, but a legitimate no-allowance route may never reach it.
The approval-history check may be used as an optimization signal.
It cannot be used as a semantic gate for whether a transfer is trace-worthy.
7. “Every outbound transfer” is not the implemented coverage
The final report’s absolute wording is broader than the public code.
A. Only ERC-20 / ERC-721 Transfer-topic rows enter this pipeline
The event model includes:
TRANSFER_ERC20
TRANSFER_ERC721
and the transfer indexer selects the shared ERC-20/ERC-721 Transfer(address,address,uint256) topic.
There is no corresponding ERC-1155:
TransferSingle
TransferBatch
event type or intake path in the reviewed feature.
Therefore, an ERC-1155 operator transfer is not part of this “every outbound transfer” classifier.
B. Rows are intentionally skipped before tracing
The code records UNKNOWN without tracing when:
transaction predates tracing support
token is flagged as spam
no approval event exists for owner/token pair
These are coverage states, not completed classifications.
C. Some chains are explicitly unsupported
The implementation deny-lists chains whose configured nodes cannot supply the required traces or frame logs, including Arbitrum Nova and several other networks.
That is reasonable engineering.
It means the feature works on a trace-compatible subset, not every chain supported by the wider Revoke.cash product.
D. Arbitrum Classic history is not traced by this route
The report describes “era-aware handling of the Classic to Nitro transition.”
The public configuration sets Arbitrum One tracing support from block:
22,207,817
because Classic-era blocks provide arbtrace_*, not the debug_traceTransaction path required by this implementation.
That is a clean coverage boundary, but it is not deep classification of Classic-era transfer history.
E. Trace failures remain unresolved
Trace failures become UNKNOWN, are retried after 24 hours and can become permanently failed after 30 days.
Again, this is a legitimate operational state.
It is not an attributed transfer.
The report should therefore distinguish:
candidate outbound transfers
classified transfers
skipped transfers
unsupported-standard transfers
pre-trace-era transfers
trace-failed transfers
unresolved transfers
Without the denominator and those classes, “every transfer” is not a measurable claim.
8. The current flat model is insufficient
The root design problem is that spenderAddress is being asked to represent several different objects:
token-level executor
approved spender
Permit2 caller
mediating contract
effective application
Those identities may coincide in simple ERC-20 routes.
They do not coincide in nested authorization systems.
The result should preserve the authority chain rather than compressing it into one address.
A minimum model would look like:
type AuthorizationMechanism =
| 'DIRECT_OWNER'
| 'TOKEN_ALLOWANCE'
| 'OPERATOR_APPROVAL'
| 'PERMIT2_ALLOWANCE'
| 'PERMIT2_SIGNATURE'
| 'EIP3009_SIGNATURE'
| 'SMART_ACCOUNT_DELEGATION'
| 'CUSTOM_AUTHORITY'
| 'UNRESOLVED';
type AuthorityPersistence =
| 'NONE'
| 'SINGLE_USE'
| 'TIME_BOUNDED'
| 'STANDING'
| 'UNKNOWN';
interface TransferAuthorityResult {
classificationStatus:
| 'CLASSIFIED'
| 'UNRESOLVED'
| 'UNSUPPORTED_STANDARD'
| 'UNSUPPORTED_CHAIN'
| 'PRE_TRACE_ERA'
| 'TRACE_FAILED'
| 'SKIPPED';
authorizationMechanism: AuthorizationMechanism;
authorityPersistence: AuthorityPersistence;
owner: Address;
token: Address;
recipient: Address;
amountOrTokenId: bigint;
tokenExecutor?: Address;
mediator?: Address;
effectiveCaller?: Address;
approvedSpender?: Address;
revocationTarget?: Address;
revocableNow?: boolean;
selectorPath: Hex[];
traceFramePath: number[];
transactionHash: Hex;
logIndex: number;
evidenceReason: string;
}
The exact schema can differ.
The essential rule is:
executor, mediator, effective caller and authorization mechanism must not be collapsed into one spenderAddress.
9. Required Permit2 correction
The Permit2 branch should decode and distinguish all four SignatureTransfer entry families:
permitTransferFrom(single)
permitTransferFrom(batch)
permitWitnessTransferFrom(single)
permitWitnessTransferFrom(batch)
For each transfer item, the classifier should bind:
owner
token
permitted maximum
requested amount
recipient
nonce
deadline
witness, when present
Permit2 caller
Permit2 contract
matching ERC-20 Transfer log
The result should be:
authorizationMechanism = PERMIT2_SIGNATURE
authorityPersistence = SINGLE_USE
effectiveCaller = caller of Permit2
mediator = Permit2
tokenExecutor = Permit2
approvedSpender = undefined
For Permit2 AllowanceTransfer, the result should remain distinct:
authorizationMechanism = PERMIT2_ALLOWANCE
authorityPersistence = TIME_BOUNDED or STANDING
effectiveCaller = caller of Permit2
mediator = Permit2
approvedSpender = caller of Permit2
The UI should reflect that distinction.
Example:
Permit2 allowance transfer
Spender: <application>
Via: Permit2
versus:
Permit2 signed transfer
Requested by: <application>
Via: Permit2
Authority: one-time signature
10. Required indexer correction
The approval-event check should not decide whether an outbound transfer is eligible for tracing.
Replace:
no approval event
→ do not trace
with:
approval history present
→ high-priority trace candidate
approval history absent
→ still trace; signature/direct/custom route remains possible
If cost control requires prioritization, use staged scheduling:
priority 1:
known active approval / Permit2 relationship / high-risk spender
priority 2:
known historical approval
priority 3:
no approval history, but outbound transfer exists
priority 4:
spam or low-value policy bucket
But the final coverage ledger must retain the skipped population and must never convert absence of approval history into evidence that no approval-derived or signature-derived route exists.
11. Required coverage correction
The History feature should expose a coverage object per wallet and chain:
indexed outbound events
eligible standards
classified
direct
standing allowance
operator approval
Permit2 allowance
Permit2 signature
EIP-3009 signature
custom/other
unresolved
skipped as spam
pre-trace era
trace failed
unsupported standard
unsupported chain
Then a statement such as:
98.7% of eligible ERC-20/ERC-721 outbound Transfer events
on trace-supported blocks were classified
can be verified.
The current statement:
every outbound transfer is classified
cannot be verified from the implemented route because the system itself contains explicit skip, unknown, unsupported and permanently failed states.
12. Acceptance tests that should block milestone closure
A corrected implementation should pass at least the following deterministic fixtures.
1. ERC-20 standing allowance
owner approves spender
spender calls token.transferFrom
Expected:
TOKEN_ALLOWANCE
effective caller = spender
persistent authority = STANDING
2. Permit2 AllowanceTransfer — single
Expected:
PERMIT2_ALLOWANCE
effective caller = application
mediator = Permit2
approved spender = application
3. Permit2 AllowanceTransfer — batch
Every child Transfer log must bind to the correct item and application.
4. Permit2 SignatureTransfer — single
Expected:
PERMIT2_SIGNATURE
effective caller = application
mediator = Permit2
approved spender = none
persistence = SINGLE_USE
It must not display Permit2 as the approved spender.
5. Permit2 SignatureTransfer — batch
Each transfer item must be matched independently.
6. Permit2 witness transfer — single and batch
The result must preserve the witness-bearing signature class.
7. EIP-3009 with no Approval history
The transaction must reach the trace classifier and become:
EIP3009_SIGNATURE
It must not be skipped by the approval-history prefilter.
8. Authorization-class mutation
Take the same semantic token movement and change only the authority route:
Permit2 allowance
→
Permit2 signature
The classification must change.
If the UI result remains “Approved Transfer” in both cases, the test must fail.
9. Nested multicall / router
The classifier must preserve:
token executor
mediator
effective caller
authorization mechanism
through nested calls.
10. Smart-account execution
A smart-account transaction initiated by an authorized module must not be reduced to:
direct owner transfer
without preserving the delegated execution route.
11. ERC-1155 operator transfer
The coverage system must either classify it or explicitly report:
UNSUPPORTED_STANDARD
It must not disappear from an “every outbound transfer” denominator.
12. Arbitrum Classic transfer
Expected:
PRE_TRACE_ERA
with the exact support boundary.
13. Trace backend degradation
A trace without frame logs must produce:
TRACE_FAILED
and remain visible in coverage.
14. Duplicate identical Transfer logs
Each database row must bind to the correct trace frame and log index without ancestor/duplicate ambiguity.
15. Revocation-action test
The recommended remediation must differ between:
standing allowance
and
consumed one-time signature
13. Required claim correction
Until these cases pass, the public description should not say:
every outbound transfer is classified
every transfer is attributed to its spender
correct attribution through Permit2
the only foolproof way
A report-safe description would be:
The feature uses transaction traces to classify supported ERC-20/ERC-721 outbound Transfer events on trace-compatible blocks and networks. It distinguishes direct, allowance-based and selected signature-authorized routes, while retaining unresolved and unsupported cases explicitly.
After the Permit2 SignatureTransfer correction and a published coverage report, stronger claims can be made using exact denominators.
Conclusion
The delivered trace infrastructure is valuable. Moving from transaction-sender heuristics to execution traces was the correct architectural direction.
But the final step is incomplete.
The current system finds the token-moving call, then compresses a multi-layer authority route into a flat spender classification.
That compression breaks specifically at Permit2 SignatureTransfer:
application
→ one-time signed Permit2 authority
→ Permit2
→ token.transferFrom
becomes:
Approved Transfer
Approved Spender: Permit2
The visible result therefore does not preserve the authority that caused the movement.
This is the hidden defect behind the otherwise strong trace-based implementation:
execution tracing is present, but authorization provenance is not fully reconstructed.
The feature should be corrected around a three-layer invariant:
execution identity
+
authorization identity
+
attribution identity
No transfer should be presented as an Approved Transfer unless the evidence path identifies the exact persistent approval or operator authority that was consumed.
One-time signature authorization must remain one-time signature authorization.
Permit2 as mediator must remain distinct from the application that called it.
Unknown coverage must remain visible as unknown.
Once those boundaries are enforced, the History tab can become a genuinely reliable forensic record rather than a trace-backed interface that still collapses materially different authority classes.