Skip to content

fix: only scan send payees for address poisoning - #9943

Merged
adonesky1 merged 12 commits into
mainfrom
fix/address-poisoning-send-recipients-only
Sep 2, 2026
Merged

adonesky1 merged 12 commits into
mainfrom
fix/address-poisoning-send-recipients-only

Conversation

@adonesky1

@adonesky1 adonesky1 commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Explanation

Restrict address poisoning known recipients to user-chosen send payees so confirmed approves, swaps, and contract calls stop putting token and protocol addresses into the comparison set.

Summary

Export getSendRecipients and hydrate the phishing known-recipient set from it instead of getEffectiveRecipient, which falls back to txParams.to for every non-transfer type.

Problem

Address poisoning is a payee mixup: the user previously sent to Alice, an attacker grinds a lookalike EOA, and the user pastes from history. We were also storing token, Permit2, router, and other contract to values from confirmed approves and contract interactions. Vanity token factories (shared prefix plus suffix) then 4+4-matched each other, which is what fired on the o1.exchange Permit2 approve: on Base, dogue (0xb2000000000000000000003779298b7eB3D8d501) matched Base Uncle (0xb200000000000000000000c02ce1aA07c9E3d501) at 22 prefix characters plus the shared suffix d501.

getEffectiveRecipient is the right helper for first-time interaction (the contract to is who we are calling). It is the wrong helper for poisoning.

Solution

Add getSendRecipients next to getEffectiveRecipient and use it only for poisoning hydration.

Included:

  • simpleSend to, preferring txParamsOriginal when present
  • Decoded to / _to for the three transfer methods: transfer, transferFrom, safeTransferFrom (no fallback to the token contract)
  • swapAndSendRecipient
  • Nested batch calls that themselves are sends or token transfers
  • Untyped transactions with no calldata, treated as legacy native sends

Not included: approves and Permit2, swap and swapApproval, bridges, staking, and generic contract calls. Note swapAndSend is deliberately not in that list, since its recipient is entered by the user.

Also not included, and worth being explicit about: ERC-1155 safeBatchTransferFrom. There is no TransactionType for it, so it classifies as contractInteraction and its payees are never scanned. That is pre-existing behaviour inherited from getEffectiveRecipient rather than something this PR changes, but it does mean "ERC-1155 transfers are covered" is only true of the single-transfer method.

getEffectiveRecipient is unchanged so first-time interaction keeps using the contract to.

Two deliberate details a reviewer might question:

  • txParamsOriginal is swapped in as a whole object rather than field by field, unlike useTransferRecipient in the clients. It is a cloneDeep snapshot, so its to and data always describe the same call. Mixing an original to with a wrapped data would misread an untyped transaction as a contract call and drop a real payee.
  • swapAndSendRecipient is added unconditionally rather than gated on TransactionType.swapAndSend. It is only ever populated from the payee the user entered in the swap-and-send flow, and gating on the type as well would be dead code.

Follow-up client PR: MetaMask/metamask-extension#45724

Risk

  • Existing known sets that currently contain token/router addresses will drop those entries on next hydration. #knownRecipients is in-memory and rebuilt in the constructor, so this self-heals on the next controller construction with no migration.
  • A later send to a lookalike EOA will no longer match against a token the user only approved, which is correct.
  • Clients still on the old phishing-controller keep the old known set until they bump. Extension pins ^17.4.0 and mobile pins ^17.3.1, so both need a bump PR after this releases. Until then the extension fix is candidate-side only.

Verification

Unit coverage in this PR:

  • getSendRecipients returns [] for tokenMethodApprove and contractInteraction, and returns the decoded payee (not the token contract) for the three transfer types
  • PhishingController does not add token contracts from confirmed approve transactions, and hydrates swapAndSendRecipient rather than the swap contract

Manual verification of the known-set change

The false positive in the report is fixed on the extension side alone, because an approve confirmation stops producing a candidate at all. That means the extension PR's manual steps cannot observe this PR's change: with no candidate, the known set is never consulted. To see the known-set cleanup you have to probe it from the send side.

Setup

Needs a preview build of this package, since the extension pins @metamask/phishing-controller@^17.4.0.

In metamask-extension, on #45724, add a top-level previewBuilds block to package.json and run yarn install:

"previewBuilds": {
  "@metamask/phishing-controller": {
    "type": "non-breaking",
    "previewVersion": "17.4.0-preview-878689a26"
  },
  "@metamask/transaction-controller": {
    "type": "non-breaking",
    "previewVersion": "69.6.1-preview-878689a26"
  }
}

Both are needed: the preview phishing-controller depends on transaction-controller@^69.6.1, which the extension's ^69.5.1 pin does not satisfy, so without the second entry you get a second copy on disk. Confirm the install with yarn why @metamask-previews/transaction-controller, not yarn why @metamask/transaction-controller; once remapped, the copies live under the preview scope. Do not commit the package.json or yarn.lock changes, and do not delete the vendored getSendRecipients in that PR, which still has to build against the published ^69.5.1.

Then yarn dist and load the build. Everything below runs in the page console at https://metamask.github.io/test-dapp/ with the account connected.

Helpers, paste once

Assigns to window so it survives separate pastes. Pasting a bare const from = ... in one block and using it in the next is what produces from is not defined.

window.mm = {
  // approve(0x000000000022D473030F116dDEE9F6B43aC78BA3, 0)
  APPROVE_PERMIT2_ZERO:
    '0x095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba30000000000000000000000000000000000000000000000000000000000000000',
  async tx(params) {
    const chainId = await ethereum.request({ method: 'eth_chainId' });
    if (chainId !== '0x2105') {
      throw new Error(`Switch MetaMask to Base. Current chainId: ${chainId}`);
    }
    const [from] = await ethereum.request({ method: 'eth_requestAccounts' });
    const hash = await ethereum.request({
      method: 'eth_sendTransaction',
      params: [{ from, ...params }],
    });
    console.log('submitted', hash);
    return hash;
  },
};
console.log('ready');

1. Seed the known set

Confirms an approve(Permit2, 0) on Base Uncle. On the published phishing-controller this puts the token contract into the known set; with this PR it does not. Approving 0 needs no token balance.

mm.tx({
  to: '0xb200000000000000000000c02ce1aA07c9E3d501',
  data: mm.APPROVE_PERMIT2_ZERO,
});

Confirm it and wait for confirmed status in Activity. #getRecipientAddressesFromTransaction returns [] for anything not yet confirmed, so running step 2 while this is pending gives a false pass.

Expect a trust-signal alert on this confirmation regardless of this PR: the address scan returns Warning for Base Uncle. Separate detector, unrelated.

2. Probe the known set from the send side

0xb20011…d501 scores prefix 4 / suffix 4 against the Base Uncle contract, exactly the detector's threshold. A dapp-initiated native send with no calldata types as simpleSend, so it does produce a candidate and the known set is consulted.

mm.tx({
  to: '0xb20011111111111111111111111111111111d501',
  value: '0x0',
});

Expected: no address poisoning warning. Cancel rather than confirming, so the probe address stays out of the known set.

BEFORE:

image (6)

AFTER: using the same address from the original report

Video depicting using the same contract address with no address poisoning detection when compared against a similar looking contract:
https://github.com/user-attachments/assets/b1688fa2-2d2d-4868-9f70-eef3d44c89b7

To see the failing side, remove the previewBuilds block, yarn install, rebuild, and repeat steps 1 and 2. The published ^17.4.0 warns here, comparing the send address against the token contract the approve stored.

3. Control: the known set still holds real payees

Confirm this one, wait for confirmed status:

mm.tx({
  to: '0x1234000000000000000000000000000000009abc',
  value: '0x0',
});

Then submit the lookalike and cancel it:

mm.tx({
  to: '0x1234ffffffffffffffffffffffffffffffff9abc',
  value: '0x0',
});

Expected: the address poisoning warning appears. This must hold with the preview installed. If step 2 and step 3 both come back silent, the detector is broken rather than the fix working.

Real poisoning for entered native send poisoning still working as expected

Screen.Recording.2026-08-31.at.4.08.06.PM.mov

Step 2 is the only check that distinguishes this PR from the extension PR. Note the known set is rebuilt in the PhishingController constructor from confirmed transaction state, so there is nothing to clear after swapping builds: restarting the extension re-derives it.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Changes how address-poisoning known recipients are derived from transaction history, which can reduce false positives but also narrows the comparison set; real send-based poisoning detection should remain covered by tests and address book entries.

Overview
Adds and exports getSendRecipients in @metamask/transaction-controller so callers can resolve user-chosen payees (native sends, decoded ERC-20/721/1155 transfers including batch, swapAndSendRecipient, nested batch sends) while excluding approves, swaps, and generic contract to addresses. getEffectiveRecipient is unchanged for “who we’re calling” flows; docs now steer poisoning checks toward the new helper.

PhishingController builds its in-memory known-recipient set from getSendRecipients instead of getEffectiveRecipient, so confirmed token approves and similar txs no longer seed lookalike comparisons against token/router contracts. Invalid hex recipients are still dropped via existing normalization.

Tests cover getSendRecipients edge cases (cancellations, speed-ups, txParamsOriginal, batches) and phishing hydration (approve txs, swap-and-send payee vs swap contract).

Reviewed by Cursor Bugbot for commit b5a186b. Bugbot is set up for automated code reviews on this repo. Configure here.

Alex Donesky and others added 3 commits August 31, 2026 12:02
Confirmed approves, swaps, and contract calls were adding token and
protocol addresses to the known-recipient set, which made vanity token
contracts look like poisoning matches. Only hydrate and compare against
user-chosen send payees.
- Move the phishing-controller changelog entry back under [Unreleased]; the
  rebase onto main landed it inside the released 17.4.0 section.
- Drop the unreachable `swapAndSend` branch in `getSendRecipientFromSource`.
  `getSendRecipients` already adds `swapAndSendRecipient` unconditionally, and
  without the branch a `swapAndSend` type falls through to `undefined` anyway.
  Removing it also lets nested transactions pass straight through, so the
  wrapper and the `NestedTransactionMetadata` import are gone.
- Document why `txParamsOriginal` is swapped in as a whole object rather than
  field by field, which is where this deliberately differs from
  `useTransferRecipient` in the clients.
- Fix lint: return type on `addRecipient`, `toStrictEqual` in the new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adonesky1
adonesky1 force-pushed the fix/address-poisoning-send-recipients-only branch from 17ae26b to 8763e16 Compare August 31, 2026 17:27
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adonesky1

ghost commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@metamaskbot publish-previews

@github-actions

ghost commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Preview builds have been published. Learn how to use preview builds in other projects.

Expand for full list of packages and versions.
@metamask-previews/account-tree-controller@8.0.0-preview-878689a26
@metamask-previews/accounts-controller@39.1.1-preview-878689a26
@metamask-previews/address-book-controller@7.1.2-preview-878689a26
@metamask-previews/ai-controllers@1.0.0-preview-878689a26
@metamask-previews/analytics-controller@2.0.0-preview-878689a26
@metamask-previews/analytics-data-regulation-controller@0.0.0-preview-878689a26
@metamask-previews/announcement-controller@8.1.0-preview-878689a26
@metamask-previews/app-metadata-controller@2.0.1-preview-878689a26
@metamask-previews/approval-controller@9.0.2-preview-878689a26
@metamask-previews/assets-controller@14.0.2-preview-878689a26
@metamask-previews/assets-controllers@111.1.3-preview-878689a26
@metamask-previews/authenticated-user-storage@3.0.2-preview-878689a26
@metamask-previews/base-controller@9.1.0-preview-878689a26
@metamask-previews/base-data-service@1.0.0-preview-878689a26
@metamask-previews/bitcoin-regtest-up@1.0.0-preview-878689a26
@metamask-previews/bridge-controller@80.1.0-preview-878689a26
@metamask-previews/bridge-status-controller@75.4.0-preview-878689a26
@metamask-previews/build-utils@3.0.4-preview-878689a26
@metamask-previews/chain-agnostic-permission@1.7.0-preview-878689a26
@metamask-previews/chomp-api-service@4.0.1-preview-878689a26
@metamask-previews/claims-controller@0.6.1-preview-878689a26
@metamask-previews/client-controller@1.0.1-preview-878689a26
@metamask-previews/client-utils@2.1.1-preview-878689a26
@metamask-previews/compliance-controller@2.1.0-preview-878689a26
@metamask-previews/composable-controller@12.0.1-preview-878689a26
@metamask-previews/config-registry-controller@3.1.0-preview-878689a26
@metamask-previews/connectivity-controller@0.3.0-preview-878689a26
@metamask-previews/controller-utils@12.3.0-preview-878689a26
@metamask-previews/core-backend@9.0.0-preview-878689a26
@metamask-previews/delegation-controller@3.0.2-preview-878689a26
@metamask-previews/earn-controller@12.2.6-preview-878689a26
@metamask-previews/eip-5792-middleware@3.0.5-preview-878689a26
@metamask-previews/eip-7702-internal-rpc-middleware@0.1.1-preview-878689a26
@metamask-previews/eip1193-permission-middleware@2.0.1-preview-878689a26
@metamask-previews/eth-block-tracker@15.0.1-preview-878689a26
@metamask-previews/eth-json-rpc-middleware@24.0.1-preview-878689a26
@metamask-previews/eth-json-rpc-provider@6.0.1-preview-878689a26
@metamask-previews/foundryup@1.0.1-preview-878689a26
@metamask-previews/gas-fee-controller@26.3.2-preview-878689a26
@metamask-previews/gator-permissions-controller@5.0.2-preview-878689a26
@metamask-previews/geolocation-controller@1.0.0-preview-878689a26
@metamask-previews/java-tron-up@1.0.0-preview-878689a26
@metamask-previews/json-rpc-engine@10.5.0-preview-878689a26
@metamask-previews/json-rpc-middleware-stream@8.0.8-preview-878689a26
@metamask-previews/keyring-controller@27.1.1-preview-878689a26
@metamask-previews/kyc-controller@0.0.0-preview-878689a26
@metamask-previews/local-node-utils@1.0.0-preview-878689a26
@metamask-previews/logging-controller@9.0.0-preview-878689a26
@metamask-previews/message-manager@14.1.2-preview-878689a26
@metamask-previews/messenger@2.0.0-preview-878689a26
@metamask-previews/messenger-cli@0.2.0-preview-878689a26
@metamask-previews/money-account-api-data-service@0.4.1-preview-878689a26
@metamask-previews/money-account-balance-service@2.4.3-preview-878689a26
@metamask-previews/money-account-controller@1.0.0-preview-878689a26
@metamask-previews/money-account-upgrade-controller@3.0.2-preview-878689a26
@metamask-previews/money-account-utils@1.1.0-preview-878689a26
@metamask-previews/multichain-account-service@13.0.2-preview-878689a26
@metamask-previews/multichain-api-middleware@4.0.3-preview-878689a26
@metamask-previews/multichain-network-controller@3.2.4-preview-878689a26
@metamask-previews/multichain-transactions-controller@7.1.2-preview-878689a26
@metamask-previews/name-controller@9.1.2-preview-878689a26
@metamask-previews/network-connection-banner-controller@0.2.1-preview-878689a26
@metamask-previews/network-controller@36.0.0-preview-878689a26
@metamask-previews/network-enablement-controller@6.0.5-preview-878689a26
@metamask-previews/notification-services-controller@26.0.1-preview-878689a26
@metamask-previews/passkey-controller@3.1.0-preview-878689a26
@metamask-previews/permission-controller@13.1.1-preview-878689a26
@metamask-previews/permission-log-controller@5.1.0-preview-878689a26
@metamask-previews/perps-controller@15.0.0-preview-878689a26
@metamask-previews/phishing-controller@17.4.0-preview-878689a26
@metamask-previews/platform-api-docs@0.0.0-preview-878689a26
@metamask-previews/polling-controller@16.0.9-preview-878689a26
@metamask-previews/preferences-controller@23.1.0-preview-878689a26
@metamask-previews/profile-metrics-controller@4.0.3-preview-878689a26
@metamask-previews/profile-sync-controller@29.0.0-preview-878689a26
@metamask-previews/ramps-controller@20.2.0-preview-878689a26
@metamask-previews/rate-limit-controller@7.0.1-preview-878689a26
@metamask-previews/react-data-query@1.0.0-preview-878689a26
@metamask-previews/remote-feature-flag-controller@6.1.0-preview-878689a26
@metamask-previews/sample-controllers@5.0.6-preview-878689a26
@metamask-previews/seedless-onboarding-controller@10.1.1-preview-878689a26
@metamask-previews/selected-network-controller@26.1.7-preview-878689a26
@metamask-previews/sentinel-api-service@1.0.1-preview-878689a26
@metamask-previews/shield-controller@6.0.1-preview-878689a26
@metamask-previews/signature-controller@39.2.10-preview-878689a26
@metamask-previews/smart-transactions-controller@25.1.1-preview-878689a26
@metamask-previews/snap-account-service@2.1.2-preview-878689a26
@metamask-previews/social-controllers@2.8.0-preview-878689a26
@metamask-previews/solana-test-validator-up@1.0.0-preview-878689a26
@metamask-previews/stellar-quickstart-up@0.0.0-preview-878689a26
@metamask-previews/storage-service@1.0.2-preview-878689a26
@metamask-previews/subscription-controller@8.0.1-preview-878689a26
@metamask-previews/transaction-controller@69.6.1-preview-878689a26
@metamask-previews/transaction-pay-controller@27.1.0-preview-878689a26
@metamask-previews/user-operation-controller@41.2.9-preview-878689a26
@metamask-previews/wallet@12.0.2-preview-878689a26
@metamask-previews/wallet-cli@0.0.0-preview-878689a26

@adonesky1
adonesky1 marked this pull request as ready for review August 31, 2026 21:28
@adonesky1
adonesky1 requested review from a team as code owners August 31, 2026 21:28
Comment thread packages/transaction-controller/src/utils/recipient.ts
Comment thread packages/transaction-controller/src/utils/recipient.ts
Two gaps flagged by review, both false negatives that would let getSendRecipients silently
miss a real payee rather than misclassify a protocol address:

- speedUpTransaction sets type: retry and keeps the original txParams unchanged, storing the
  prior type in originalType. getSendRecipients only ever looked at type, so a confirmed
  speed-up of any send yielded no recipient. Resolve type through originalType for retries;
  leave cancel alone, since stopTransaction overwrites to/data into a self-send that has no
  real payee to track.

- determineTransactionType only returns simpleSend when to is not a contract, so a plain
  native transfer with no calldata to a contract address (a Safe, a smart-contract wallet, many
  exchange deposit addresses) is typed contractInteraction. isNativeSendType required an exact
  simpleSend match, so these payees were dropped on both the known-set and candidate side.
  Treat contractInteraction with no calldata as a native send; a contractInteraction that does
  carry calldata is left alone, since that calldata could be an arbitrary payable call rather
  than a plain transfer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
imblue-dabadee
imblue-dabadee previously approved these changes Sep 1, 2026

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 comments but LGTM!

Comment thread packages/transaction-controller/src/utils/recipient.test.ts
transaction.swapAndSendRecipient,
);

return Array.from(

ghost Sep 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: getSendRecipients dedupes so don't need this anymore and can just return the new function.

Comment thread packages/transaction-controller/src/utils/recipient.ts
@matthewwalsh0
matthewwalsh0 requested a review from jpuri September 1, 2026 11:01
stopTransaction's #retryTransaction only overwrites txParams (into a self-send),
type, and originalType when building the cancellation's TransactionMeta; it never
clears nestedTransactions. A cancelled batch keeps its original nested legs even
though none of them executed, so getSendRecipients' unconditional nested loop was
returning those stale payees as if the user had actually sent to them.

Skip the nested loop when type is cancel. A sped-up (retry) batch still processes
its nested transactions, since those calls do execute on-chain unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Alex Donesky added 2 commits September 1, 2026 09:41
Verify ERC-721 and ERC-1155 safe transfers contribute their decoded payees to address poisoning recipient checks.
Decode safeBatchTransferFrom payees even though those calls classify as contract interactions, so address poisoning checks cover both top-level and nested transfers.
Comment thread packages/transaction-controller/src/utils/recipient.ts
Resolve the transaction-controller changelog conflict while preserving the unreleased getSendRecipients entry.
imblue-dabadee
imblue-dabadee previously approved these changes Sep 1, 2026
Comment thread packages/transaction-controller/src/utils/recipient.ts
Comment thread packages/transaction-controller/src/utils/recipient.ts
Return before reading any recipient metadata for cancellations and their retries so stale original params, nested calls, and swap recipients cannot enter the poisoning set.
@cursor
cursor Bot requested a review from Gudahtt September 1, 2026 15:40

ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ac85c31. Configure here.

Comment thread packages/transaction-controller/src/utils/recipient.ts
Infer transfer methods from original calldata when wrapping replaces transaction classification, preserving address-poisoning payees without broadening ordinary contract interactions.
imblue-dabadee
imblue-dabadee previously approved these changes Sep 2, 2026

ghost left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants