Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **BREAKING:** Remove `BackendWebsocketDataSource` and its factory/types (`BackendWebsocketDataSource`, `createBackendWebsocketDataSource`, `BackendWebsocketDataSourceOptions`, `BackendWebsocketDataSourceState`). Real-time balance updates and per-chain status are now consumed from `AccountActivityService` via `AccountActivityDataSource`, which manages the WebSocket connection and subscriptions. Consumers no longer need to delegate `BackendWebSocketService` actions/events to the `AssetsController` messenger ([#9517](https://github.com/MetaMask/core/pull/9517))

### Fixed

- Exempt assets acquired through user-initiated activity from occurrence-floor / Blockaid spam filtering while keeping the filter for passive discovery (auto-detection, airdrops):
- `AccountActivityDataSource` inspects websocket transfers and lists assets on a new `DataResponse.userInteractedAssets` field when the account sent funds in the same transaction (e.g. a swap), so swap outputs always show even for brand-new tokens below the floor
- `TokenDataSource` bypasses spam filtering for `userInteractedAssets` (like custom assets); incoming-only websocket updates (spam airdrops) remain subject to the occurrence floor
- Filtered-out spam is now also removed from stub `assetsInfo` on the response, and balance deletions match asset IDs case-insensitively

## [11.3.1]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,138 @@ describe('AccountActivityDataSource', () => {
cleanup();
});

it('marks assets as userInteractedAssets when the account sent funds in the message', async () => {
// Swap: the account pays USDC and receives NEWTOKEN in the same tx.
// Both assets must be exempt from spam filtering downstream.
const paidAsset =
'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId;
const receivedAsset =
'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId;
const account = createMockAccount();
const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({
groupAccounts: [account],
getAssetType: () => 'erc20',
});

triggerBalanceUpdated({
address: EVM_ADDRESS,
chain: CHAIN_MAINNET,
updates: [
createBalanceUpdate({
asset: { type: paidAsset, unit: 'USDC', decimals: 6 },
postBalance: { amount: '0' },
transfers: [
{ from: EVM_ADDRESS, to: '0xpool', amount: '1000000' },
],
}),
createBalanceUpdate({
asset: { type: receivedAsset, unit: 'NEW', decimals: 18 },
postBalance: { amount: '1000000000000000000' },
transfers: [
{ from: '0xpool', to: EVM_ADDRESS, amount: '1000000000000000000' },
],
}),
],
});

await Promise.resolve();

expect(onAssetsUpdate).toHaveBeenCalledTimes(1);
const [response] = onAssetsUpdate.mock.calls[0];
expect(response.userInteractedAssets).toStrictEqual([
paidAsset,
receivedAsset,
]);

cleanup();
});

it('does not mark userInteractedAssets for incoming-only transfers (airdrops)', async () => {
const airdroppedAsset =
'eip155:1/erc20:0x2222222222222222222222222222222222222222' as Caip19AssetId;
const account = createMockAccount();
const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({
groupAccounts: [account],
getAssetType: () => 'erc20',
});

triggerBalanceUpdated({
address: EVM_ADDRESS,
chain: CHAIN_MAINNET,
updates: [
createBalanceUpdate({
asset: { type: airdroppedAsset, unit: 'SPAM', decimals: 18 },
postBalance: { amount: '1000000000000000000' },
transfers: [
{
from: '0x9999999999999999999999999999999999999999',
to: EVM_ADDRESS,
amount: '1000000000000000000',
},
],
}),
],
});

await Promise.resolve();

expect(onAssetsUpdate).toHaveBeenCalledTimes(1);
const [response] = onAssetsUpdate.mock.calls[0];
expect(response.userInteractedAssets).toBeUndefined();
expect(response.assetsBalance[account.id][airdroppedAsset]).toBeDefined();

cleanup();
});

it('seeds stub assetsInfo for erc20 tokens from websocket updates', async () => {
const erc20Asset =
'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId;
const account = createMockAccount();
const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({
groupAccounts: [account],
getAssetType: () => 'erc20',
});

triggerBalanceUpdated({
address: EVM_ADDRESS,
chain: CHAIN_MAINNET,
updates: [
createBalanceUpdate({
asset: {
type: erc20Asset,
unit: 'USDC',
decimals: 6,
},
postBalance: { amount: '1000000' },
}),
],
});

await Promise.resolve();

expect(onAssetsUpdate).toHaveBeenCalledTimes(1);
const [response] = onAssetsUpdate.mock.calls[0];
// eslint-disable-next-line jest/prefer-strict-equal
expect(response).toEqual({
updateMode: 'merge',
assetsBalance: {
[account.id]: {
[erc20Asset]: { amount: '1' },
},
},
assetsInfo: {
[erc20Asset]: {
type: 'erc20',
symbol: 'USDC',
name: 'USDC',
decimals: 6,
},
},
});

cleanup();
});

it.each([
['address is empty', { address: '' }],
['chain is empty', { chain: '' }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,48 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME);
// BALANCE UPDATE PROCESSING
// ============================================================================

/**
* Whether the account actively sent funds in this activity message — i.e. the
* update was caused by a transaction the user participated in (swap, payment)
* rather than a passive incoming transfer (airdrop, unsolicited spam send).
*
* @param updates - Balance updates from account-activity websocket payload.
* @param address - The account address the message is for.
* @returns True when any transfer in the message has the account as sender.
*/
function isUserInitiatedActivity(
updates: BalanceUpdate[],
address: string,
): boolean {
const addressLower = address.toLowerCase();
return updates.some((update) =>
update.transfers?.some(
(transfer) => transfer.from?.toLowerCase() === addressLower,
),
);
}

/**
* Convert AccountActivityMessage balance updates into a {@link DataResponse}
* for AssetsController.
*
* When the message is user-initiated (the account sent funds in the same
* transaction — see {@link isUserInitiatedActivity}), its assets are listed
* on `userInteractedAssets` so TokenDataSource exempts them from
* occurrence-floor / Blockaid spam filtering (a swap output chosen by the
* user must always show, even for a brand-new token). Passive incoming
* transfers get no exemption, so spam airdrops are still filtered.
*
* @param updates - Balance updates from account-activity websocket payload.
* @param accountId - Internal account UUID.
* @param address - The account address the message is for.
* @param getAssetType - Resolver for asset metadata type.
* @returns DataResponse with merge mode when balances are present.
*/
function processAccountActivityBalanceUpdates(
updates: BalanceUpdate[],
accountId: string,
address: string,
getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl',
): DataResponse {
const assetsBalance = Object.create(null) as Record<
Expand Down Expand Up @@ -93,9 +123,13 @@ function processAccountActivityBalanceUpdates(
}

const response: DataResponse = { updateMode: 'merge' };
if (Object.keys(assetsBalance[accountId]).length > 0) {
const assetIds = Object.keys(assetsBalance[accountId]) as Caip19AssetId[];
if (assetIds.length > 0) {
response.assetsBalance = assetsBalance;
response.assetsInfo = assetsMetadata;
if (isUserInitiatedActivity(updates, address)) {
response.userInteractedAssets = assetIds;
}
}

return response;
Expand Down Expand Up @@ -285,6 +319,7 @@ export class AccountActivityDataSource extends AbstractDataSource<
const response = processAccountActivityBalanceUpdates(
updates,
account.id,
address,
(assetId) => this.#getAssetType(assetId),
);

Expand Down
Loading
Loading