[Final Report] Revoke.cash

Hi all, we’ve recently completed the grant milestones for the Revoke.cash Questbook Grant

Milestone 1 — Approved Transfers in the History Tab

Status: Complete. The History tab now shows transfers that were triggered by an approval, alongside the existing approve/revoke events, searchable and filterable like the rest of the tab.

What was delivered

Every eligible outbound transfer of a tracked wallet is classified by tracing the transaction (with debug_traceTransaction) and locating the exact call that moved the tokens so the result is read from actual execution. Each “Approved Transfer” row shows which approved spender pulled the tokens, with correct attribution through Permit2 (labeled in the UI). The feature works on most supported networks, including Arbitrum One and all supported Orbit chains. The minority of networks whose nodes cannot serve usable traces are excluded.

On the delay

The original May timeline was based on a naive design: show only *whether* a transfer was approval-triggered by checking if the transaction sender matches the account. That check fails for smart wallets and many similar cases, so it had to be replaced with trace-based classification (which is the only foolproof way to determine the correct data). That redesign required server-side indexing infrastructure that didn’t exist yet (and was deployed in production in July 2026) and it had to work across 100+ chains with wildly varying tracing behavior. The upside: the shipped feature attributes every transfer to its spender, significantly more useful than the yes/no indicator originally scoped.

On premium-only availability

The History tab itself remains free for everyone, running client-side at no server cost, like all core revoke.cash safety tooling. Transfer classification is different: it requires one archival trace per transaction plus continuous per-wallet indexing and storage. This comes with recurring operational costs, not a one-time build cost as initially estimated. Premium ties those costs to a revenue stream instead of the project’s grant and donation budget, which is what keeps the free product free.

Milestone 2 — Continuous Maintenance for Arbitrum One and Orbit Chains

Status: Complete (ongoing). Throughout the past year, revoke.cash has maintained full product support (approval scanning, revocation, batch revoking, history, exploit checking) for Arbitrum One, Arbitrum Nova, Arbitrum Sepolia, ApeChain, Degen Chain, and Gravity Alpha. We also recently added support for Animechain. On Arbitrum One, an average of 8k approvals have been revoked by 6k users. This reflects the overall down-trend of usage we’ve seen in 2026 across all networks. Maintenance also includes:

  • Routine upkeep: 33 chain-configuration maintenance commits: RPC endpoint rotations, replacing degraded public endpoints with paid infrastructure, and continuous scanning / uptime verification.

  • New chains: newly launched ecosystem chains added, most recently Animechain.

  • Lifecycle management: RARI Chain removed on its deprecation, Gravity Alpha scheduled for removal ahead of its November 2026 shutdown, Arbitrum Nova supported through its transition to maintenance mode.

  • Infrastructure costs: the grant offsets recurring costs like (archive) RPC access, hosting, and database infrastructure.

2 Likes

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.

Nice to see the grant milestones completed. The trace based approach for identifying approved transfers is a meaningful improvement because it provides accurate spender attribution, including Permit2 activity, rather than a basic yes or no signal.

The maintenance across Arbitrum One and Orbit chains also shows ongoing operational commitment. It would be useful to share a few measurable metrics in future reports, such as active users, approvals revoked, exploit checks performed, and uptime across supported chains, to better assess ecosystem impact. @roscokalis

Hi @cxclrfx, thanks for your detailed review.

On Permit2 SignatureTransfer attribution (1,2,3,4,5)

This is something that we took into consideration during the implementation, and we believe that the current way to handle it is correct within the context of Revoke.cash as a tool to manage token approvals.

The question the user wants to answer is “which approval enabled this transfer”. In case of a Permit2 AllowanceTransfer, we attribute that to the approved spender of the Permit2-approval and include a reference that it is a Permit2 approval.

In case of a SignatureTransfer, you correctly state that there is no Permit2-approval that is consumed. However, this is still a transfer that required an approval to function (i.e. the regular ERC20 approval to the Permit2 contract). In that sense it is not so different from a Uniswap swap that requires the approval AND the swap transaction, while SignatureTransfer also requires the approval AND the signature.

That is why we still attribute SignatureTransfer as an “Approved Transfer” with the Permit2 contract itself as the spender. Another decision about this (such as the one mentioned by your report) can also be correct, but we believe our approach on this to be the most useful in the context of Revoke.cash.

On Approval History gating & intentional skipping (6, 7B)

If we go back to the goal of this classifier, it is to distinguish between “Approved Transfers” and essentially “everything else”. To do that, we did add additional categories of classification, but those are a means to an end. By definition, a transfer cannot be an “Approved Transfer” if there exists no approval for the token.

Additionally, the other ‘skipped’ states also hold: if the transaction cannot be traced (predates tracing support in the chain), we cannot trace / classify it. If a token is flagged as spam, then we don’t ever display anything about it to the user, so tracing it would not make a difference.

On ERC1155 Transfers (7A)

This is a good point. Currently ERC20/721 support is explicit, but ERC1155 approval support is a byproduct of ERC721 support rather than a first-class citizen. We do want to add support for this, but this is something to add in the future.

On chain support (7C, 7D) and trace failures (7E)

It is correct that not every chain is supported since not every chain has the required public infrastructure to support tracing. This is mentioned in the report. Arbitrum Classic is not covered, I agree that the wording in the report should cover that, I will update the report. I don’t think there’s a real value in differentiating between all those classes of potential transfers in the report.

On suggested changes to implementation (8, 9, 10)

The suggested model changes seem very over-engineered, and contains no justification for why the simple flat model is insufficient for the use-case and context of this feature on Revoke.cash. The other suggested changes were addressed earlier above.

On coverage / report updates (11, 12, 13)

I feel like these sections are really splitting hairs, but I can change the report to be more technically correct.

Hey @MconnectDAO thank you for the response. I’ll update the report to include more detailed usage stats for Arbitrum and Orbit chains later today.

1 Like

Nice update. For regular wallet users, a simple explanation of what an “approved transfer” means and what they should do next would be very helpful. Security tools can be confusing when users see unfamiliar activity.

I help with support at Gem Wallet, so making these warnings easy to understand is important.

1 Like

Your response confirms the narrower product question you now want the feature to answer:

Which persistent approval made this transfer possible?

That is a valid question, but it is not the same question as the Final Report’s claims of spender attribution, correct Permit2 attribution, and attributing each transfer to the spender that moved the tokens.

More importantly, narrowing the wording does not close the implementation defect.

Permit2 SignatureTransfer contains two simultaneous authority facts

For a Permit2 SignatureTransfer, both of the following are true:

approvalEnabled      = YES
signatureAuthorized  = YES

Specifically:

persistent ERC-20 approval target = Permit2
one-time signed spender            = application / msg.sender

This is an AND relationship, not an OR relationship.

The official Permit2 interface states that the user still signs over a spender address, and that the spender is required to be msg.sender:

ISignatureTransfer.sol

The official EIP-712 hash makes that binding explicit:

PermitTransferFrom(
    TokenPermissions permitted,
    address spender,
    uint256 nonce,
    uint256 deadline
)

and Permit2 inserts msg.sender into the signed hash:

abi.encode(
    _PERMIT_TRANSFER_FROM_TYPEHASH,
    tokenPermissionsHash,
    msg.sender,
    permit.nonce,
    permit.deadline
)

PermitHash.sol

The application is therefore not an incidental caller. It is the spender cryptographically authorized by the owner.

The application also supplies the final:

recipient
requestedAmount

through SignatureTransferDetails, subject to the signed maximum. Permit2 verifies the signature, nonce, deadline and amount bound, then executes the application-supplied transfer details:

SignatureTransfer.sol

The complete route is therefore:

owner grants persistent ERC-20 approval to Permit2
AND
owner signs application as one-time spender
AND
application supplies recipient and requested amount
AND
Permit2 verifies and executes the token transfer

Reducing that route to:

APPROVED
spenderAddress = Permit2

does not merely simplify the display. It removes the spender that the owner actually signed.

The current flat model cannot represent the real state

The current enum is mutually exclusive:

APPROVED
or
SIGNATURE_AUTHORIZED

But Permit2 SignatureTransfer requires:

APPROVED            = true
SIGNATURE_AUTHORIZED = true

A single mutually exclusive classification cannot represent both facts.

Choosing APPROVED necessarily discards the signature-authorized authority.

Choosing SIGNATURE_AUTHORIZED alone would discard the persistent ERC-20 approval prerequisite.

That is the concrete justification for why the flat model is insufficient.

This is not a preference about labels. It is a structural limitation of the data model.

The public implementation shows the resulting loss:

token child call:
    ERC20.transferFrom(...)
    msg.sender = Permit2

current result:
    classification = APPROVED
    spenderAddress = Permit2

The special parent-level attribution only recognizes Permit2 AllowanceTransfer selectors. It does not reconstruct:

permitTransferFrom(...)
permitWitnessTransferFrom(...)

Therefore, in the SignatureTransfer route:

signed spender = application

is not preserved in the result.

trace-classifier.ts

This is precisely why the previous review separated:

execution identity
authorization identity
attribution identity
authority persistence

The complete model was not added complexity for its own sake. It followed the actual authority sequence already present in the transaction.

A deeper loss also exists in witness and batch routes

For permitWitnessTransferFrom, the witness is included in the signed hash. That witness can bind application-specific order, route or intent data.

Flattening the result to a generic Approved Transfer loses the fact that the authority was bound to an additional signed intent object.

For a batch SignatureTransfer, one signed spender, nonce, deadline and optional witness can produce several token transfers. Rendering those transfers only as independent Approved Transfer rows also fragments one shared authority object into several unrelated-looking events.

These are consequences of the same root defect:

The trace identifies the token execution, but the result does not preserve the signed authority object that caused it.

Minimum correction

If the product intends to show only the revocation target, then the field and claim should say exactly that:

approvalTarget = Permit2

It should not be presented as:

spenderAddress = Permit2

because the signed spender is the application.

If the feature retains the claim of spender attribution, the minimum result is not a large forensic framework. It is simply:

approvalEnabled       = true
approvalTarget        = Permit2

signatureAuthorized   = true
authorizationType     = PERMIT2_SIGNATURE
signedSpender         = application

mediator               = Permit2
authorityPersistence   = SINGLE_USE

A minimal UI can then state:

Permit2 signed transfer

Token approval: Permit2
Signed spender: <application>
Authorization: one-time signature
Via: Permit2

This preserves both true facts without changing the product into a different system.

Blocking test

The smallest deterministic test is:

same owner
same token
same application
same recipient
same amount

Case A:
    Permit2 AllowanceTransfer

Case B:
    Permit2 SignatureTransfer

Expected:

Case A:
    approvalTarget = Permit2
    authorizationType = PERMIT2_ALLOWANCE
    spender = application
    persistence = STANDING or TIME_BOUNDED

Case B:
    approvalTarget = Permit2
    authorizationType = PERMIT2_SIGNATURE
    signedSpender = application
    persistence = SINGLE_USE

If both cases collapse into the same Approved Transfer authority class, or if Case B reports Permit2 as the spender while discarding the signed application, the test must fail.

Conclusion

Updating usage statistics, ERC-1155 scope, Arbitrum Classic wording or general report language does not alter this code path.

Those changes may make the report more precise, but they do not restore the authority information lost by the classifier.

The original finding therefore remains open:

Permit2 is the persistent ERC-20 approval target and execution mediator. The application is the one-time spender cryptographically signed by the user. Both facts are true, and neither can replace the other.

The remaining implementation question is exact:

Where is the signed spender represented in the current SignatureTransfer result?