diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cc77f42c19a..af8709babee 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -160,11 +160,6 @@ "count": 3 } }, - "packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": { - "no-restricted-syntax": { - "count": 6 - } - }, "packages/assets-controller/src/data-sources/AccountsApiDataSource.ts": { "no-restricted-syntax": { "count": 1 diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 92fc2853e14..8050360edcc 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Clean up spam assets on keyring unlock, gated behind the `assetsUnifyState` remote feature flag's `useUnlockCleanup` property (disabled unless the flag explicitly enables it) ([#9973](https://github.com/MetaMask/core/pull/9973)) + ## [14.0.2] ### Changed diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 3c9b5625292..5e2b4f48747 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -94,6 +94,7 @@ "@types/lodash": "^4.14.191", "deepmerge": "^4.2.2", "jest": "^30.4.2", + "nock": "^13.3.1", "ts-jest": "^29.4.11", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts b/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts new file mode 100644 index 00000000000..7d5cea67a8a --- /dev/null +++ b/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts @@ -0,0 +1,206 @@ +import type { ApiPlatformClient } from '@metamask/core-backend'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; + +import { + createMockAssetControllerMessenger, + createMockInternalAccount, + registerAssetsControllerActions, +} from './__fixtures__/MockAssetControllerMessenger.js'; +import type { MockRootMessenger } from './__fixtures__/MockAssetControllerMessenger.js'; +import { + createTestApiClient, + waitForTokenApiRequests, +} from './__fixtures__/mockTokenApi.js'; +import { mockSweepApis } from './__fixtures__/scam-token-cleanup/api-responses/index.js'; +import { + SCAM_WALLET_ACCOUNT_ADDRESS, + SCAM_WALLET_ACCOUNT_ID, + SCAM_WALLET_CUSTOM_ASSETS, + SCAM_WALLET_NATIVE_ASSET_IDENTIFIERS, + SCAM_WALLET_SPAM_ASSET_IDS, + SCAM_WALLET_SURVIVING_ASSET_IDS, + buildScamWalletState, +} from './__fixtures__/scam-token-cleanup/scamWalletState.js'; +import { waitFor } from './__fixtures__/test-utils.js'; +import { AssetsController } from './AssetsController.js'; +import type { AssetsControllerState } from './AssetsController.js'; + +/** + * True-to-life integration coverage for the unlock-time scam-token sweep. + * Unlike `AssetsController.spam-cleanup.test.ts`, which drives a hand-built + * wallet and hand-written API bodies, this boots the controller from the + * example scam-token wallet state (see + * `__fixtures__/scam-token-cleanup/scamWalletState.ts`) and answers the + * Token/Tokens API from captured live responses (see + * `__fixtures__/scam-token-cleanup/api-responses/`). Only the messenger and + * the HTTP boundary are mocked. + */ + +/** + * Mirror & subset of `assetsUnifyState` + */ +const UNLOCK_CLEANUP_ENABLED_FLAGS = { + assetsUnifyState: { useUnlockCleanup: true }, +}; + +type WithControllerOptions = { + state?: Partial; + queryApiClient?: ApiPlatformClient; + isBasicFunctionality?: () => boolean; + captureException?: (error: Error) => void; + remoteFeatureFlags?: FeatureFlags; +}; + +type WithControllerCallback = (args: { + controller: AssetsController; + messenger: MockRootMessenger; +}) => Promise; + +async function withController( + { + state = buildScamWalletState(), + queryApiClient = createTestApiClient(), + isBasicFunctionality = (): boolean => true, + captureException, + remoteFeatureFlags = UNLOCK_CLEANUP_ENABLED_FLAGS, + }: WithControllerOptions, + fn: WithControllerCallback, +): Promise { + const { rootMessenger, assetsControllerMessenger } = + createMockAssetControllerMessenger({ delegateGetState: false }); + + // Every account the wallet tracks balances for: the synthetic catch-all + // account plus the real custom-asset owner. + const accounts = [ + createMockInternalAccount({ + id: SCAM_WALLET_ACCOUNT_ID, + address: SCAM_WALLET_ACCOUNT_ADDRESS, + metadata: { name: 'Spam Wallet' } as InternalAccount['metadata'], + }), + ...Object.keys(SCAM_WALLET_CUSTOM_ASSETS).map((accountId) => + createMockInternalAccount({ + id: accountId, + address: '0x5c269fd64c004dd3df2c44ca5d25fbe7ab959e02', + metadata: { name: 'Imported USDC' } as InternalAccount['metadata'], + }), + ), + ]; + + registerAssetsControllerActions(rootMessenger, { + accounts, + enabledNetworkMap: { eip155: { '1': true, '10': true, '8453': true } }, + nativeAssetIdentifiers: SCAM_WALLET_NATIVE_ASSET_IDENTIFIERS, + remoteFeatureFlags, + }); + + const controller = new AssetsController({ + messenger: assetsControllerMessenger, + state, + queryApiClient, + isBasicFunctionality, + captureException, + }); + + try { + return await fn({ controller, messenger: rootMessenger }); + } finally { + controller.destroy(); + } +} + +describe('AssetsController scam-token cleanup (example state)', () => { + it('sweeps the airdrop scam tokens out of the example wallet on unlock', async () => { + mockSweepApis(); + + await withController({}, async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + // Every captured sub-floor airdrop is gone. + for (const spamId of SCAM_WALLET_SPAM_ASSET_IDS) { + expect(controller.state.assetsInfo[spamId]).toBeUndefined(); + } + // And the surviving set is exactly the genuine holdings, the custom + // import, the mUSD entries and every native / non-EVM asset. + expect(Object.keys(controller.state.assetsInfo).sort()).toStrictEqual( + [...SCAM_WALLET_SURVIVING_ASSET_IDS].sort(), + ); + }); + }); + }); + + it('keeps the hand-imported custom asset and clears spam from balances', async () => { + mockSweepApis(); + const state = buildScamWalletState(); + + await withController({ state }, async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + // The custom Arbitrum USDC survives whatever its occurrence count. + expect(controller.state.customAssets).toStrictEqual( + SCAM_WALLET_CUSTOM_ASSETS, + ); + // No swept asset is left behind in any account's balances. + for (const balances of Object.values(controller.state.assetsBalance)) { + for (const spamId of SCAM_WALLET_SPAM_ASSET_IDS) { + expect(balances[spamId]).toBeUndefined(); + } + } + }); + }); + }); + + it('asks the Tokens API about every sweepable ERC-20 and nothing else', async () => { + const { requestedBatches } = mockSweepApis(); + + await withController({}, async ({ messenger }) => { + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + const requested = requestedBatches.flat(); + // Natives, non-EVM assets and the default-tracked mUSD are never sent. + expect(requested).not.toContain( + 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + ); + expect(requested.some((id) => !id.startsWith('eip155:'))).toBe(false); + // Spam and genuine sweepable ERC-20s are both queried. + expect(requested.length).toBeGreaterThan(0); + }); + }); + }); + + it('does not sweep before the wallet is unlocked', async () => { + mockSweepApis(); + + await withController({}, async ({ controller }) => { + await new Promise((resolve) => setTimeout(resolve, 250)); + + // The full 83-asset registry is untouched until unlock. + expect(Object.keys(controller.state.assetsInfo)).toHaveLength(83); + }); + }); + + it('does not sweep when the useUnlockCleanup feature flag is off', async () => { + mockSweepApis(); + + await withController( + { + remoteFeatureFlags: { + assetsUnifyState: { useUnlockCleanup: false }, + }, + }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 250)); + + // The full 83-asset registry is untouched while the flag is off. + expect(Object.keys(controller.state.assetsInfo)).toHaveLength(83); + }, + ); + }); +}); diff --git a/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts b/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts new file mode 100644 index 00000000000..79a2466a66f --- /dev/null +++ b/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts @@ -0,0 +1,414 @@ +import type { ApiPlatformClient } from '@metamask/core-backend'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; + +import { + createMockAssetControllerMessenger, + createMockInternalAccount, + registerAssetsControllerActions, +} from './__fixtures__/MockAssetControllerMessenger.js'; +import type { MockRootMessenger } from './__fixtures__/MockAssetControllerMessenger.js'; +import { + createTestApiClient, + mockSuggestedOccurrenceFloors, + mockV3Assets, + waitForTokenApiRequests, +} from './__fixtures__/mockTokenApi.js'; +import { + ACCOUNT_ONE_ADDRESS, + ACCOUNT_ONE_ID, + ACCOUNT_TWO_ADDRESS, + ACCOUNT_TWO_ID, + ARBITRUM_GMX, + BASE_FARTCOIN, + MAINNET_NATIVE, + MAINNET_SPAM, + MAINNET_USDT, + OPTIMISM_SPAM, + OPTIMISM_USDC, + SEI_USDCN, + SPAM_WALLET_ASSETS_INFO, + SURVIVING_ASSET_IDS, + SWEEPABLE_ASSET_IDS, + buildAssetsInfo, + buildSpamWalletState, +} from './__fixtures__/spamWalletState.js'; +import { waitFor } from './__fixtures__/test-utils.js'; +import { AssetsController } from './AssetsController.js'; +import type { AssetsControllerState } from './AssetsController.js'; + +/** + * End-to-end coverage for the spam sweep the controller runs on + * `KeyringController:unlock`, driven through a real `ApiPlatformClient` with + * the Token API stubbed at the HTTP boundary. Lives outside + * `AssetsController.test.ts`, which is already very large. + */ + +/** + * Mirror & subset of `assetsUnifyState` + */ +const UNLOCK_CLEANUP_ENABLED_FLAGS = { + assetsUnifyState: { useUnlockCleanup: true }, +}; + +type WithControllerOptions = { + state?: Partial; + queryApiClient?: ApiPlatformClient; + isBasicFunctionality?: () => boolean; + captureException?: (error: Error) => void; + remoteFeatureFlags?: FeatureFlags; +}; + +type WithControllerCallback = (args: { + controller: AssetsController; + messenger: MockRootMessenger; +}) => Promise; + +/** + * Construct a controller wired to a root messenger with the handlers its + * constructor and data sources need, then tear it down afterwards. + * + * @param options - Controller overrides. + * @param options.state - Persisted state to boot from. + * @param options.queryApiClient - The API client to query. + * @param options.isBasicFunctionality - Basic functionality getter. + * @param options.captureException - Sentry-compatible failure reporter. + * @param options.remoteFeatureFlags - Remote feature flags (defaults to the + * spam sweep being enabled so existing sweep tests keep running). + * @param fn - Callback run with the controller and messenger. + * @returns Whatever the callback returns. + */ +async function withController( + { + state = buildSpamWalletState(), + queryApiClient = createTestApiClient(), + isBasicFunctionality = (): boolean => true, + captureException, + remoteFeatureFlags = UNLOCK_CLEANUP_ENABLED_FLAGS, + }: WithControllerOptions, + fn: WithControllerCallback, +): Promise { + const { rootMessenger, assetsControllerMessenger } = + createMockAssetControllerMessenger({ delegateGetState: false }); + const accounts = [ + createMockInternalAccount({ + id: ACCOUNT_ONE_ID, + address: ACCOUNT_ONE_ADDRESS, + metadata: { name: 'Account 1' } as InternalAccount['metadata'], + }), + createMockInternalAccount({ + id: ACCOUNT_TWO_ID, + address: ACCOUNT_TWO_ADDRESS, + metadata: { name: 'Account 2' } as InternalAccount['metadata'], + }), + ]; + + registerAssetsControllerActions(rootMessenger, { + accounts, + enabledNetworkMap: { eip155: { '1': true, '10': true } }, + nativeAssetIdentifiers: { 'eip155:1': MAINNET_NATIVE }, + remoteFeatureFlags, + }); + + const controller = new AssetsController({ + messenger: assetsControllerMessenger, + state, + queryApiClient, + isBasicFunctionality, + captureException, + }); + + try { + return await fn({ controller, messenger: rootMessenger }); + } finally { + controller.destroy(); + } +} + +describe('AssetsController spam cleanup', () => { + it('stops tracking spam tokens across the whole wallet when it is unlocked', async () => { + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + + await withController({}, async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + expect(controller.state.assetsInfo).toStrictEqual( + buildAssetsInfo(SURVIVING_ASSET_IDS), + ); + expect(controller.state.assetsBalance).toStrictEqual({ + [ACCOUNT_ONE_ID]: { + [MAINNET_NATIVE]: { amount: '1204500000000000000' }, + [MAINNET_USDT]: { amount: '2500000000' }, + [OPTIMISM_USDC]: { amount: '148230000' }, + [SEI_USDCN]: { amount: '74500000' }, + }, + [ACCOUNT_TWO_ID]: { + [BASE_FARTCOIN]: { amount: '1200000000000000000000' }, + [ARBITRUM_GMX]: { amount: '3400000000000000000' }, + }, + }); + }); + }); + }); + + it('never asks the Token API about assets it is not allowed to sweep', async () => { + mockSuggestedOccurrenceFloors(); + const { requestedBatches } = mockV3Assets(); + + await withController({}, async ({ messenger }) => { + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + expect([...requestedBatches[0]].sort()).toStrictEqual( + [...SWEEPABLE_ASSET_IDS].sort(), + ); + }); + }); + }); + + it('leaves prices and the imported token alone', async () => { + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + const state = buildSpamWalletState(); + + await withController({ state }, async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + expect(controller.state.customAssets).toStrictEqual({ + [ACCOUNT_TWO_ID]: [ARBITRUM_GMX], + }); + expect(controller.state.assetsPrice).toStrictEqual(state.assetsPrice); + }); + }); + }); + + it('writes the swept state in a single update', async () => { + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + + await withController({}, async ({ messenger }) => { + const stateChanges: AssetsControllerState[] = []; + ( + messenger as unknown as { + subscribe: ( + topic: string, + handler: (state: AssetsControllerState) => void, + ) => void; + } + ).subscribe('AssetsController:stateChanged', (state) => { + stateChanges.push(state); + }); + + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + expect(stateChanges).toHaveLength(1); + expect(stateChanges[0].assetsInfo[MAINNET_SPAM]).toBeUndefined(); + }); + }); + }); + + it('does not sweep before the wallet is unlocked', async () => { + const floorsScope = mockSuggestedOccurrenceFloors(); + const { scope: assetsScope } = mockV3Assets(); + + await withController({}, async ({ controller }) => { + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(floorsScope.isDone()).toBe(false); + expect(assetsScope.isDone()).toBe(false); + expect(controller.state.assetsInfo).toStrictEqual( + SPAM_WALLET_ASSETS_INFO, + ); + }); + }); + + it('does not sweep while basic functionality is off', async () => { + const floorsScope = mockSuggestedOccurrenceFloors(); + mockV3Assets(); + + await withController( + { isBasicFunctionality: () => false }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(floorsScope.isDone()).toBe(false); + expect(controller.state.assetsInfo).toStrictEqual( + SPAM_WALLET_ASSETS_INFO, + ); + }, + ); + }); + + it('does not sweep when the useUnlockCleanup feature flag is off', async () => { + const floorsScope = mockSuggestedOccurrenceFloors(); + const { scope: assetsScope } = mockV3Assets(); + + await withController( + { + remoteFeatureFlags: { + assetsUnifyState: { useUnlockCleanup: false }, + }, + }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(floorsScope.isDone()).toBe(false); + expect(assetsScope.isDone()).toBe(false); + expect(controller.state.assetsInfo).toStrictEqual( + SPAM_WALLET_ASSETS_INFO, + ); + }, + ); + }); + + it('does not sweep when the useUnlockCleanup feature flag is missing', async () => { + const floorsScope = mockSuggestedOccurrenceFloors(); + const { scope: assetsScope } = mockV3Assets(); + + await withController( + { remoteFeatureFlags: {} }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(floorsScope.isDone()).toBe(false); + expect(assetsScope.isDone()).toBe(false); + expect(controller.state.assetsInfo).toStrictEqual( + SPAM_WALLET_ASSETS_INFO, + ); + }, + ); + }); + + it('leaves the wallet untouched and reports when the Token API is down', async () => { + mockSuggestedOccurrenceFloors({ status: 503 }); + const state = buildSpamWalletState(); + const captureException = jest.fn(); + + await withController( + { state, captureException }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('503'), + }), + ); + }); + + expect(controller.state.assetsInfo).toStrictEqual( + SPAM_WALLET_ASSETS_INFO, + ); + expect(controller.state.assetsBalance).toStrictEqual( + state.assetsBalance, + ); + }, + ); + }); + + it('sweeps again on the next unlock, since occurrence counts move', async () => { + // USDT clears mainnet's floor of four on the first unlock, and is missing + // from the token lists by the second. + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + + await withController({}, async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + expect(controller.state.assetsInfo[MAINNET_USDT]).toStrictEqual( + SPAM_WALLET_ASSETS_INFO[MAINNET_USDT], + ); + }); + + mockSuggestedOccurrenceFloors(); + mockV3Assets({ occurrences: {} }); + messenger.publish('KeyringController:lock'); + messenger.publish('KeyringController:unlock'); + await waitForTokenApiRequests(); + + await waitFor(() => { + expect(controller.state.assetsInfo[MAINNET_USDT]).toBeUndefined(); + expect(controller.state.assetsBalance[ACCOUNT_ONE_ID]).toStrictEqual({ + [MAINNET_NATIVE]: { amount: '1204500000000000000' }, + }); + }); + }); + }); + + it('sweeps during a normal app start, alongside asset tracking', async () => { + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + + await withController({}, async ({ controller, messenger }) => { + ( + messenger as unknown as { + publish: (topic: string, payload?: unknown) => void; + } + ).publish('ClientController:stateChange', { isUiOpen: true }); + messenger.publish('KeyringController:unlock'); + (messenger.publish as CallableFunction)( + 'AccountTreeController:initialized', + {}, + ); + await waitForTokenApiRequests(); + + await waitFor(() => { + expect(controller.state.assetsInfo[MAINNET_SPAM]).toBeUndefined(); + expect(controller.state.assetsInfo[OPTIMISM_SPAM]).toBeUndefined(); + expect(controller.state.assetsInfo[OPTIMISM_USDC]).toStrictEqual( + SPAM_WALLET_ASSETS_INFO[OPTIMISM_USDC], + ); + }); + }); + }); + + it('preserves concurrent state updates during the sweep', async () => { + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + + await withController({}, async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + + // Simulate a concurrent update while the sweep is awaiting the Token API + // @ts-expect-error - we are forcing a concurrent update to the state + controller.update((state) => { + return { + ...state, + assetsInfo: { + ...state.assetsInfo, + 'eip155:1/erc20:0xconcurrent': { + type: 'erc20', + symbol: 'CON', + name: 'Concurrent Token', + decimals: 18, + }, + }, + }; + }); + + await waitForTokenApiRequests(); + + await waitFor(() => { + expect(controller.state.assetsInfo[MAINNET_SPAM]).toBeUndefined(); + expect( + controller.state.assetsInfo['eip155:1/erc20:0xconcurrent'], + ).toBeDefined(); + }); + }); + }); +}); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 65a2cdba703..6cc89ac5df5 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -116,7 +116,11 @@ import { } from './middlewares/ParallelMiddleware.js'; import { RpcFallbackMiddleware } from './middlewares/RpcFallbackMiddleware.js'; import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata.js'; -import { tempHealAssetsInfoMetadata } from './migrations/healAssetsInfoMetadata.js'; +import { + cleanSpamAssets, + isUnlockCleanupEnabled, + tempHealAssetsInfoMetadata, +} from './migrations/healAssetsInfoMetadata.js'; import type { AccountId, AssetPreferences, @@ -1178,8 +1182,13 @@ export class AssetsController extends BaseController< }, clientControllerSelectors.selectIsUiOpen, ); - this.messenger.subscribe('KeyringController:unlock', () => { + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.messenger.subscribe('KeyringController:unlock', async () => { this.#keyringUnlocked = true; + await this.#runSpamCleanup().catch(() => { + /* Do nothing */ + }); this.#updateActive(); }); this.messenger.subscribe('KeyringController:lock', () => { @@ -1274,6 +1283,48 @@ export class AssetsController extends BaseController< }); } + async #runSpamCleanup(): Promise { + try { + const shouldRun = + this.#keyringUnlocked && + this.#isBasicFunctionality() && + isUnlockCleanupEnabled( + this.messenger.call('RemoteFeatureFlagController:getState') + ?.remoteFeatureFlags, + ); + if (!shouldRun) { + return; + } + } catch (error) { + log('Failed to start spam cleanup', { error }); + return; + } + + try { + const originalState = this.state; + const result = await cleanSpamAssets({ + state: originalState, + apiClient: this.#queryApiClient, + captureException: this.#captureException, + }); + + if (!result) { + return; + } + + this.update((state) => { + result.applyPatch( + state as Pick, + { + spamAssetIds: result.spamAssetIds, + }, + ); + }); + } catch (error) { + log('Failed to run spam cleanup', { error }); + } + } + /** * Start or stop asset tracking based on client (UI) open state, keyring * unlock state, and account-tree readiness. Only runs when the UI is open, diff --git a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts index 537ca99dcc2..594fd545916 100644 --- a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts +++ b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts @@ -1,5 +1,6 @@ import { defaultAbiCoder } from '@ethersproject/abi'; import * as ProviderModule from '@ethersproject/providers'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { MOCK_ANY_NAMESPACE, Messenger, @@ -7,12 +8,9 @@ import { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; -import { NetworkStatus } from '@metamask/network-controller'; -import { - NetworkState, - RpcEndpoint, - RpcEndpointType, -} from '@metamask/network-controller/src/NetworkController'; +import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; +import type { NetworkState } from '@metamask/network-controller'; +import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; import { AssetsControllerMessenger, @@ -33,10 +31,14 @@ export type MockRootMessenger = Messenger< const MAINNET_CHAIN_ID_HEX = '0x1'; const MOCK_CHAIN_ID_CAIP = 'eip155:1'; -export function createMockAssetControllerMessenger(): { +export function createMockAssetControllerMessenger(options?: { + delegateGetState?: boolean; +}): { rootMessenger: MockRootMessenger; assetsControllerMessenger: AssetsControllerMessenger; } { + const { delegateGetState = true } = options ?? {}; + const rootMessenger: MockRootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -50,8 +52,9 @@ export function createMockAssetControllerMessenger(): { messenger: assetsControllerMessenger, actions: [ // AssetsController + 'AccountsController:getSelectedAccount', 'AccountTreeController:getAccountsFromSelectedAccountGroup', - 'AssetsController:getState', + ...(delegateGetState ? ['AssetsController:getState' as const] : []), // RpcDataSource 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', 'NetworkController:getState', @@ -62,7 +65,12 @@ export function createMockAssetControllerMessenger(): { 'SnapController:getRunnableSnaps', 'SnapController:handleRequest', 'PermissionController:getPermissions', + // PhishingController + 'PhishingController:bulkScanTokens', + // AccountsApiDataSource + 'RemoteFeatureFlagController:getState', ], + /* eslint-disable no-restricted-syntax */ events: [ // AssetsController 'AccountTreeController:selectedAccountGroupChange', @@ -73,18 +81,26 @@ export function createMockAssetControllerMessenger(): { 'KeyringController:lock', 'KeyringController:unlock', 'PreferencesController:stateChange', + 'TransactionController:unapprovedTransactionAdded', // RpcDataSource, StakedBalanceDataSource 'NetworkController:stateChange', 'TransactionController:transactionConfirmed', + 'NetworkController:networkAdded', + 'NetworkController:networkDidChange', + 'NetworkController:networkRemoved', // StakedBalanceDataSource 'NetworkEnablementController:stateChange', // SnapDataSource 'AccountsController:accountBalancesUpdated', 'PermissionController:stateChange', + 'SnapController:snapInstalled', // AccountActivityService (real-time balances + chain status) 'AccountActivityService:balanceUpdated', 'AccountActivityService:statusChanged', + // AccountsApiDataSource + 'RemoteFeatureFlagController:stateChange', ], + /* eslint-enable no-restricted-syntax */ }); return { @@ -127,7 +143,7 @@ export function registerStakedMessengerActions( networkConfigurationsByChainId: { [MAINNET_CHAIN_ID_HEX]: { chainId: MAINNET_CHAIN_ID_HEX, - rpcEndpoints: [{ networkClientId: 'mainnet' }] as RpcEndpoint[], + rpcEndpoints: [{ networkClientId: 'mainnet' }] as TestMockType, defaultRpcEndpointIndex: 0, blockExplorerUrls: [], name: 'Mainnet', @@ -243,3 +259,119 @@ export function createMockNetworkState( }, } as unknown as NetworkState; } + +export type RegisterAssetsControllerActionsOptions = { + accounts?: InternalAccount[]; + selectedAccount?: InternalAccount; + enabledNetworkMap?: Record>; + nativeAssetIdentifiers?: Record; + networkState?: NetworkState; + remoteFeatureFlags?: FeatureFlags; + clientControllerState?: { isUiOpen: boolean }; +}; + +/** + * Build a mock internal account with sensible defaults. + * + * @param overrides - Partial account to override defaults. + * @returns The internal account. + */ +export function createMockInternalAccount( + overrides?: Partial, +): InternalAccount { + const { metadata, ...rest } = overrides ?? {}; + return { + id: 'mock-account-id', + address: '0x1234567890123456789012345678901234567890', + options: {}, + methods: [], + type: 'eip155:eoa', + scopes: ['eip155:1'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: 1_756_100_000_000, + lastSelected: 1_756_200_000_000, + ...metadata, + }, + ...rest, + } as InternalAccount; +} + +/** + * Register mock action handlers for external controller actions that + * AssetsController and its data sources call. + * + * @param rootMessenger - The root mock messenger. + * @param opts - Action handler return value overrides. + */ +export function registerAssetsControllerActions( + rootMessenger: MockRootMessenger, + opts: RegisterAssetsControllerActionsOptions = {}, +): void { + const accounts = opts.accounts ?? [ + opts.selectedAccount ?? createMockInternalAccount(), + ]; + const selectedAccount = opts.selectedAccount ?? accounts[0]; + + rootMessenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + () => selectedAccount, + ); + + rootMessenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + () => accounts, + ); + + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => + ({ + enabledNetworkMap: opts.enabledNetworkMap ?? { + eip155: { [MAINNET_CHAIN_ID_HEX]: true }, + }, + nativeAssetIdentifiers: opts.nativeAssetIdentifiers ?? { + [MOCK_CHAIN_ID_CAIP]: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + }, + }) as TestMockType, + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + () => opts.networkState ?? createMockNetworkState(), + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => + ({ + provider: { request: jest.fn().mockResolvedValue('0x0') }, + configuration: { chainId: MAINNET_CHAIN_ID_HEX }, + }) as TestMockType, + ); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: opts.remoteFeatureFlags ?? {}, + cacheTimestamp: 0, + }), + ); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + () => undefined, + ); + + if (opts.clientControllerState !== undefined) { + ( + rootMessenger as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler( + 'ClientController:getState', + () => opts.clientControllerState, + ); + } +} diff --git a/packages/assets-controller/src/__fixtures__/mockTokenApi.ts b/packages/assets-controller/src/__fixtures__/mockTokenApi.ts new file mode 100644 index 00000000000..aace581815b --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/mockTokenApi.ts @@ -0,0 +1,219 @@ +import { API_URLS, ApiPlatformClient } from '@metamask/core-backend'; +import type { Json } from '@metamask/utils'; +import nock, { pendingMocks } from 'nock'; + +import { + ARBITRUM_GMX, + BASE_FARTCOIN, + BASE_USDC, + MAINNET_MUSD, + MAINNET_USDT, + MONAD_WMON, + OPTIMISM_SPAM, + OPTIMISM_USDC, + OPTIMISM_VELO, + SEI_USDCN, + SOLANA_USDC, + findTokenMetadata, +} from './spamWalletState.js'; +import { waitFor } from './test-utils.js'; + +/** + * `GET token.api.cx.metamask.io/v1/suggestedOccurrenceFloors`, verbatim. + * + * Only ten chains are listed. Optimism, Base, Arbitrum and Polygon are not + * among them and fall back to the sweep's own floor of three, while the newer + * chains — Monad, Sei, Linea — are held to one, because their token lists are + * too thin for anything stricter. + */ +export const SUGGESTED_OCCURRENCE_FLOORS: Record = { + '1': 3, + '143': 1, + '204': 1, + '232': 1, + '690': 1, + '1329': 1, + '4663': 1, + '10143': 1, + '59144': 1, + '98866': 1, +}; + +export const TOKEN_API_OCCURRENCES: Record = { + [MAINNET_MUSD]: 5, + [MAINNET_USDT]: 10, + [OPTIMISM_USDC]: 7, + [OPTIMISM_VELO]: 6, + [BASE_USDC]: 8, + [BASE_FARTCOIN]: 3, // exactly the floor Base falls back to + [SEI_USDCN]: 2, // under the fallback floor, over Sei's suggested one + [ARBITRUM_GMX]: 9, + [SOLANA_USDC]: 4, + [OPTIMISM_SPAM]: 2, // a scam token that talked its way onto two lists + [MONAD_WMON]: undefined, // as the API answers for every Monad token today +}; + +export function createTestApiClient(): ApiPlatformClient { + const client = new ApiPlatformClient({ clientProduct: 'metamask-test' }); + const { queryClient } = client; + queryClient.setDefaultOptions({ + queries: { ...queryClient.getDefaultOptions().queries, retry: false }, + }); + return client; +} + +/** + * Intercept `GET /v1/suggestedOccurrenceFloors`. + * + * @param options - Response overrides. + * @param options.floors - The floors to answer with. + * @param options.status - HTTP status to answer with. Defaults to 200. + * @param options.times - How many requests to intercept. Defaults to 1. + * @returns The nock scope. + */ +export function mockSuggestedOccurrenceFloors({ + floors = SUGGESTED_OCCURRENCE_FLOORS, + status = 200, + times = 1, +}: { + floors?: Record; + status?: number; + times?: number; +} = {}): nock.Scope { + return nock(API_URLS.TOKEN) + .get('/v1/suggestedOccurrenceFloors') + .times(times) + .reply( + status, + status === 200 ? floors : { message: 'Service Unavailable' }, + ); +} + +export type V3AssetsMock = { + scope: nock.Scope; + /** The asset IDs each intercepted request asked about, in request order. */ + requestedBatches: string[][]; +}; + +/** + * Intercept `GET /v3/assets` + * + * @param options - Response overrides. + * @param options.occurrences - Occurrence counts keyed by CAIP-19 asset ID. + * @param options.omit - Asset IDs to leave out of the response altogether, as + * the API does for chains it does not serve. + * @param options.status - HTTP status to answer with. Defaults to 200. + * @param options.times - How many requests to intercept. Defaults to 1. + * @param options.casing - The casing to answer with. Defaults to 'lowercase'. + * @returns The nock scope alongside the asset IDs each request asked about. + */ +export function mockV3Assets({ + occurrences = TOKEN_API_OCCURRENCES, + omit = [], + status = 200, + times = 1, + casing = 'lowercase', +}: { + occurrences?: Record; + omit?: string[]; + status?: number; + times?: number; + casing?: 'lowercase' | 'checksum'; +} = {}): V3AssetsMock { + const requestedBatches: string[][] = []; + const omitted = new Set(omit.map((assetId) => assetId.toLowerCase())); + + const scope = nock(API_URLS.TOKENS) + .get('/v3/assets') + .query(true) + .times(times) + .reply(status, (uri: string) => { + const assetIds = readAssetIds(uri); + requestedBatches.push(assetIds); + if (status !== 200) { + return { message: 'Service Unavailable' }; + } + return assetIds + .filter((assetId) => !omitted.has(assetId.toLowerCase())) + .map((assetId) => buildAssetResponse(assetId, occurrences, casing)) + .reverse(); + }); + + return { scope, requestedBatches }; +} + +/** + * Wait until every registered interceptor has been consumed, so tests driving + * the sweep through an event (rather than awaiting it) can wait on its real + * HTTP round trips instead of guessing at a number of microtask flushes. + * + * @param timeoutMs - How long to wait before giving up. Defaults to 2000. + */ +export async function waitForTokenApiRequests( + timeoutMs = 2_000, +): Promise { + await waitFor( + () => { + if (pendingMocks().length > 0) { + throw new Error('Mocks are still pending'); + } + }, + { timeoutMs, intervalMs: 5 }, + ); +} + +/** + * Read the requested asset IDs back off a `/v3/assets` request URI. + * + * @param uri - The intercepted request URI, path and query. + * @returns The asset IDs the caller asked about. + */ +function readAssetIds(uri: string): string[] { + const assetIds = new URL(uri, API_URLS.TOKENS).searchParams.get('assetIds'); + return assetIds ? assetIds.split(',') : []; +} + +/** + * Build one entry of a `/v3/assets` response: a described asset if the API + * carries the token, an empty stub if no list does. + * + * @param assetId - The CAIP-19 asset ID, as requested. + * @param occurrences - Occurrence counts keyed by CAIP-19 asset ID. + * @param casing - Whether to answer with lowercase or checksummed asset IDs. + * @returns The response entry. + */ +function buildAssetResponse( + assetId: string, + occurrences: Record, + casing: 'lowercase' | 'checksum' = 'lowercase', +): Json { + const lowerId = assetId.toLowerCase(); + const knownId = Object.keys(occurrences).find( + (candidate) => candidate.toLowerCase() === lowerId, + ); + const metadata = + knownId === undefined ? undefined : findTokenMetadata(knownId); + + const responseAssetId = + casing === 'checksum' && knownId !== undefined ? knownId : lowerId; + + if (knownId === undefined || metadata === undefined) { + return { + symbol: '', + name: '', + decimals: null, + address: lowerId.split(':').pop() ?? lowerId, + type: 'erc20', + assetId: responseAssetId, + }; + } + + const count = occurrences[knownId]; + return { + assetId: responseAssetId, + decimals: metadata.decimals, + name: metadata.name, + symbol: metadata.symbol, + ...(count === undefined ? {} : { occurrences: count }), + }; +} diff --git a/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/index.ts b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/index.ts new file mode 100644 index 00000000000..e4e01fb63b4 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/index.ts @@ -0,0 +1,122 @@ +/** + * Nock helpers that answer the sweep's HTTP requests from the real API + * responses captured into this directory by `../captureTokenApiResponses.ts`. + * + * Unlike `../../mockTokenApi.ts` (which synthesizes bodies by hand), these replay + * verbatim captures so the integration test exercises the controller against + * true-to-life occurrence counts and supported networks. The `/v3/assets` + * capture is keyed by the lowercased asset ID the API actually returns, so any + * batch composition is answered the way the live API would. Sourced from the + * example scam-token wallet in `../scamWalletState.ts`. + */ +import { API_URLS } from '@metamask/core-backend'; +import type { V3AssetResponse } from '@metamask/core-backend'; +import type { Json } from '@metamask/utils'; +import nock from 'nock'; + +import suggestedOccurrenceFloors from './token-api/suggestedOccurrenceFloors.js'; +import v3Assets from './tokens-api/v3-assets.js'; + +/** The captured `/v3/assets` entries, keyed by lowercased CAIP-19 asset ID. */ +const V3_ASSETS_BY_LOWER_ID = v3Assets as unknown as Record< + string, + V3AssetResponse +>; + +/** + * Intercept `GET {TOKEN}/v1/suggestedOccurrenceFloors` with the captured + * response. + * + * @returns The nock scope. + */ +export function mockSuggestedOccurrenceFloors(): nock.Scope { + return nock(API_URLS.TOKEN) + .get('/v1/suggestedOccurrenceFloors') + .reply(200, suggestedOccurrenceFloors as Record); +} + +/** + * Intercept `GET {TOKENS}/v3/assets` and answer each batch from the captured + * per-asset entries, preserving the API's reversed ordering and its lowercase + * `assetId` echo. Assets the API does not carry are answered as empty stubs, + * matching `../../mockTokenApi.ts`. + * + * @param options - Response overrides. + * @param options.times - How many requests to intercept (one per 50-asset + * batch). Defaults to as many as needed. + * @returns The nock scope and the asset IDs each request asked about. + */ +export function mockV3Assets({ + times = 1, +}: { + times?: number; +} = {}): { scope: nock.Scope; requestedBatches: string[][] } { + const requestedBatches: string[][] = []; + + const scope = nock(API_URLS.TOKENS) + .get('/v3/assets') + .query(true) + .times(times) + .reply(200, (uri: string) => { + const assetIds = readAssetIds(uri); + requestedBatches.push(assetIds); + return assetIds + .map((assetId) => lookupAsset(assetId)) + .reverse() as Json[]; + }); + + return { scope, requestedBatches }; +} + +/** + * Register interceptors for the two endpoints the unlock-time spam sweep + * (`cleanSpamAssets`) calls: `/v1/suggestedOccurrenceFloors` and the batched + * `/v3/assets`. Supported networks are not consulted at unlock time (the sweep + * uses a hardcoded Accounts-API chain list), so no interceptor is needed for + * it. + * + * @param options - Response overrides. + * @param options.assetBatches - How many `/v3/assets` requests to intercept. + * @returns The `/v3/assets` mock, including the requested batches. + */ +export function mockSweepApis({ + assetBatches = 2, +}: { + assetBatches?: number; +} = {}): { scope: nock.Scope; requestedBatches: string[][] } { + mockSuggestedOccurrenceFloors(); + return mockV3Assets({ times: assetBatches }); +} + +/** + * Read the requested asset IDs back off a `/v3/assets` request URI. + * + * @param uri - The intercepted request URI, path and query. + * @returns The asset IDs the caller asked about. + */ +function readAssetIds(uri: string): string[] { + const assetIds = new URL(uri, API_URLS.TOKENS).searchParams.get('assetIds'); + return assetIds ? assetIds.split(',') : []; +} + +/** + * Look up the captured `/v3/assets` entry for an asset, answering an empty + * stub for tokens the API does not carry (as it does for unsupported chains). + * + * @param assetId - The CAIP-19 asset ID, as requested. + * @returns The captured response entry, or an empty stub. + */ +function lookupAsset(assetId: string): V3AssetResponse { + const captured = V3_ASSETS_BY_LOWER_ID[assetId.toLowerCase()]; + if (captured) { + return captured; + } + return { + symbol: '', + name: '', + decimals: null, + address: assetId.split(':').pop() ?? assetId, + type: 'erc20', + assetId: assetId.toLowerCase(), + } as unknown as V3AssetResponse; +} diff --git a/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/token-api/suggestedOccurrenceFloors.ts b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/token-api/suggestedOccurrenceFloors.ts new file mode 100644 index 00000000000..42acc025150 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/token-api/suggestedOccurrenceFloors.ts @@ -0,0 +1,14 @@ +const suggestedOccurrenceFloors = { + '1': 3, + '143': 1, + '204': 1, + '232': 1, + '690': 1, + '1329': 1, + '4663': 1, + '10143': 1, + '59144': 1, + '98866': 1, +} as const; + +export default suggestedOccurrenceFloors; diff --git a/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/tokens-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/tokens-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..dd1ed6b30b1 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/tokens-api/v2-supportedNetworks.ts @@ -0,0 +1,69 @@ +const v2SupportedNetworks = { + fullSupport: [ + 'eip155:1', + 'eip155:10', + 'eip155:25', + 'eip155:56', + 'eip155:100', + 'eip155:137', + 'eip155:143', + 'eip155:250', + 'eip155:324', + 'eip155:1101', + 'eip155:1284', + 'eip155:1285', + 'eip155:1329', + 'eip155:8453', + 'eip155:42161', + 'eip155:42220', + 'eip155:43114', + 'eip155:59144', + 'eip155:1313161554', + 'eip155:1666600000', + 'eip155:11297108109', + 'eip155:13371', + 'eip155:534352', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'tron:728126428', + 'stellar:pubnet', + 'eip155:698', + 'eip155:16507', + 'eip155:41923', + 'eip155:747474', + 'eip155:80094', + 'eip155:33139', + 'eip155:2741', + 'eip155:1868', + 'eip155:166', + 'eip155:1440000', + 'eip155:252', + 'eip155:43111', + 'eip155:50', + 'eip155:42', + 'eip155:9745', + 'eip155:999', + 'eip155:1776', + 'eip155:4326', + 'eip155:196', + 'eip155:68414', + 'eip155:42793', + 'eip155:60808', + 'eip155:30', + 'bip122:000000000019d6689c085ae165831e93', + 'eip155:88888', + 'eip155:988', + 'eip155:42431', + 'eip155:4217', + 'eip155:5000', + 'eip155:5042', + 'eip155:4663', + 'eip155:5031', + 'eip155:16661', + 'eip155:130', + 'eip155:204', + 'eip155:81457', + ], + partialSupport: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'], +} as const; + +export default v2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/tokens-api/v3-assets.ts b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/tokens-api/v3-assets.ts new file mode 100644 index 00000000000..f4743423f72 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/api-responses/tokens-api/v3-assets.ts @@ -0,0 +1,1478 @@ +const v3Assets = { + 'eip155:1/erc20:0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c': { + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'rubic', + 'rango', + 'sonarwatch', + ], + assetId: 'eip155:1/erc20:0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + decimals: 6, + description: { + en: 'USD Coin in AAVE V3 Ethereum Market', + }, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c.png', + name: 'Aave v3 USDC', + occurrences: 6, + storage: { + balance: 52, + approval: 53, + }, + symbol: 'AUSDC', + isContractVerified: true, + }, + 'eip155:137/erc20:0xedcfb6984a3c70501baa8b7f5421ae795ecc1496': { + aggregators: ['rubic', 'rango'], + assetId: 'eip155:137/erc20:0xedcfb6984a3c70501baa8b7f5421ae795ecc1496', + decimals: 8, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/0xedcfb6984a3c70501baa8b7f5421ae795ecc1496.png', + name: 'ABCMETA Token', + occurrences: 2, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'META', + isContractVerified: true, + }, + 'eip155:56/erc20:0x683e9dcf085e5efcc7925858aace94d4b8882024': { + aggregators: ['pancakeCoinMarketCap', 'rubic', 'sonarwatch'], + assetId: 'eip155:56/erc20:0x683e9dcf085e5efcc7925858aace94d4b8882024', + decimals: 9, + description: { + en: 'TangYuan, as the first food concept Token on the blockchain, is not only a digital asset, but also carries the heritage of Chinese food culture, and TangYuan symbolizes reunion and happiness', + }, + erc20Permit: false, + fees: { + maxFee: 3, + avgFee: 0.0750000025, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x683e9dcf085e5efcc7925858aace94d4b8882024.png', + name: 'TangYuan', + occurrences: 3, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'TANGYUAN', + isContractVerified: true, + }, + 'eip155:1/erc20:0xe52d53c8c9aa7255f8c2fa9f7093fea7192d2933': { + aggregators: ['coinMarketCap', 'rubic'], + assetId: 'eip155:1/erc20:0xe52d53c8c9aa7255f8c2fa9f7093fea7192d2933', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 2.499999999999999, + maxFee: 2.5, + minFee: 2.5, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xe52d53c8c9aa7255f8c2fa9f7093fea7192d2933.png', + name: 'yield-farming.io', + occurrences: 2, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'YIELDX', + isContractVerified: true, + }, + 'eip155:1/erc20:0xc18360217d8f7ab5e7c516566761ea12ce7f9d72': { + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'trustWallet', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'bancor', + ], + assetId: 'eip155:1/erc20:0xc18360217d8f7ab5e7c516566761ea12ce7f9d72', + decimals: 18, + description: { + en: "The Ethereum Name Service (ENS) is a distributed, open, and extensible naming system based on the Ethereum blockchain.ENS’s job is to map human-readable names like ‘alice.eth’ to machine-readable identifiers such as Ethereum addresses, other cryptocurrency addresses, content hashes, and metadata. ENS also supports ‘reverse resolution’, making it possible to associate metadata such as canonical names or interface descriptions with Ethereum addresses.ENS has similar goals to DNS, the Internet’s Domain Name Service, but has significantly different architecture due to the capabilities and constraints provided by the Ethereum blockchain. Like DNS, ENS operates on a system of dot-separated hierarchical names called domains, with the owner of a domain having full control over subdomains.Top-level domains, like ‘.eth’ and ‘.test’, are owned by smart contracts called registrars, which specify rules governing the allocation of their subdomains. Anyone may, by following the rules imposed by these registrar contracts, obtain ownership of a domain for their own use. ENS also supports importing in DNS names already owned by the user for use on ENS.Because of the hierarchal nature of ENS, anyone who owns a domain at any level may configure subdomains - for themselves or others - as desired. For instance, if Alice owns 'alice.eth', she can create 'pay.alice.eth' and configure it as she wishes.ENS is deployed on the Ethereum main network and on several test networks. If you use a library such as the ensjs Javascript library, or an end-user application, it will automatically detect the network you are interacting with and use the ENS deployment on that network.", + }, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc18360217d8f7ab5e7c516566761ea12ce7f9d72.png', + name: 'Ethereum Name Service', + occurrences: 10, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'ENS', + isContractVerified: true, + }, + 'eip155:1/erc20:0xc5102fe9359fd9a28f877a67e36b0f050d81a3cc': { + aggregators: [ + 'coinMarketCap', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:1/erc20:0xc5102fe9359fd9a28f877a67e36b0f050d81a3cc', + decimals: 18, + description: { + en: 'Hop is a protocol for sending tokens across rollups and their shared layer-1 network in a quick and trustless manner.', + }, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc5102fe9359fd9a28f877a67e36b0f050d81a3cc.png', + name: 'Hop', + occurrences: 7, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'HOP', + isContractVerified: true, + }, + 'eip155:1/erc20:0x4d5f47fa6a74757f35c14fd3a6ef8e3c9bc514e8': { + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + ], + assetId: 'eip155:1/erc20:0x4d5f47fa6a74757f35c14fd3a6ef8e3c9bc514e8', + decimals: 18, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x4d5f47fa6a74757f35c14fd3a6ef8e3c9bc514e8.png', + name: 'Aave v3 WETH', + occurrences: 7, + storage: { + balance: 52, + approval: 53, + }, + symbol: 'AWETH', + isContractVerified: true, + }, + 'eip155:1/erc20:0x3b484b82567a09e2588a13d54d032153f0c0aee0': { + aggregators: [ + 'coinMarketCap', + 'oneInch', + 'trustWallet', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:1/erc20:0x3b484b82567a09e2588a13d54d032153f0c0aee0', + decimals: 18, + description: { + en: 'OpenDAO ($SOS) is a token for the NFT ecosystem. An airdrop is conducted for all users who have traded on OpenSea. Treasury holdings will be used to protect traders on OpenSea, support NFT artists/communities, and developer grant.', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x3b484b82567a09e2588a13d54d032153f0c0aee0.png', + name: 'OpenDAO', + occurrences: 8, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'SOS', + isContractVerified: true, + }, + 'eip155:1/erc20:0xaa0200d169ff3ba9385c12e073c5d1d30434ae7b': { + aggregators: ['metamask', 'oneInch', 'liFi', 'rango'], + assetId: 'eip155:1/erc20:0xaa0200d169ff3ba9385c12e073c5d1d30434ae7b', + decimals: 6, + iconUrl: '', + name: 'Aave v3 mUSD', + occurrences: 4, + storage: { + approval: 53, + balance: 52, + }, + symbol: 'AMUSD', + isContractVerified: true, + }, + 'eip155:1/erc20:0x9d24364b97270961b2948734afe8d58832efd43a': { + aggregators: ['rubic'], + assetId: 'eip155:1/erc20:0x9d24364b97270961b2948734afe8d58832efd43a', + decimals: 18, + erc20Permit: false, + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: true, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x9d24364b97270961b2948734afe8d58832efd43a.png', + name: 'yefam.finance', + occurrences: 1, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'FAM', + isContractVerified: true, + }, + 'eip155:1/erc20:0xa700b4eb416be35b2911fd5dee80678ff64ff6c9': { + aggregators: [ + 'coinGecko', + 'oneInch', + 'liFi', + 'rubic', + 'rango', + 'sonarwatch', + ], + assetId: 'eip155:1/erc20:0xa700b4eb416be35b2911fd5dee80678ff64ff6c9', + decimals: 18, + description: { + en: 'AAVE Token in AAVE V.3 ETH Market', + }, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa700b4eb416be35b2911fd5dee80678ff64ff6c9.png', + name: 'Aave v3 AAVE', + occurrences: 6, + storage: { + balance: 52, + approval: 53, + }, + symbol: 'AAAVE', + isContractVerified: true, + }, + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'trustWallet', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'bancor', + ], + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + description: { + en: 'USDC is a fully collateralized US dollar stablecoin. USDC is the bridge between dollars and trading on cryptocurrency exchanges. The technology behind CENTRE makes it possible to exchange value between people, businesses and financial institutions just like email between mail services and texts between SMS providers. We believe by removing artificial economic borders, we can create a more inclusive global economy.', + }, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + labels: ['stable_coin', 'badges:v1:stablecoin'], + name: 'USDC', + occurrences: 10, + storage: { + balance: 9, + approval: 10, + }, + symbol: 'USDC', + isContractVerified: true, + }, + 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da': { + aggregators: ['metamask', 'oneInch', 'liFi', 'rubic', 'rango'], + assetId: 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + labels: ['badges:v1:stablecoin'], + name: 'MetaMask USD', + occurrences: 5, + storage: { + approvalStr: + '92155457228465093955173560380360064720849401111993098342695494701049963192576', + balanceStr: + '107734271257630865975065146523144289492045861028913371777218889022146465165570', + }, + symbol: 'MUSD', + isContractVerified: true, + }, + 'eip155:1/erc20:0xbaa70614c7aafb568a93e62a98d55696bcc85dfe': { + aggregators: ['coinMarketCap', 'rubic'], + assetId: 'eip155:1/erc20:0xbaa70614c7aafb568a93e62a98d55696bcc85dfe', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: true, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xbaa70614c7aafb568a93e62a98d55696bcc85dfe.png', + name: 'UniCap.finance', + occurrences: 2, + storage: { + balance: 4, + approval: 5, + }, + symbol: 'UCAP', + isContractVerified: true, + }, + 'eip155:1/erc20:0xae7ab96520de3a18e5e111b5eaab095312d7fe84': { + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + ], + assetId: 'eip155:1/erc20:0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + decimals: 18, + description: { + en: 'Lido Staked Ether (stETH) is a token that represents your staked ether in Lido, combining the value of initial deposit and staking rewards. stETH tokens are minted upon deposit and burned when redeemed. stETH token balances are pegged 1:1 to the ethers that are staked by Lido and the token’s balances are updated daily to reflect earnings and rewards. stETH tokens can be used as one would use ether, allowing you to earn ETH 2.0 staking rewards whilst benefiting from e.g. yields across decentralised finance products.', + }, + erc20Permit: true, + fees: { + avgFee: 12.654644390656694, + maxFee: 12.654644390656703, + minFee: 12.654644390656703, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xae7ab96520de3a18e5e111b5eaab095312d7fe84.png', + name: 'Liquid staked Ether 2.0', + occurrences: 7, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'STETH', + isContractVerified: true, + }, + 'eip155:56/erc20:0x0400ff00ffd395ef93e701ae27087a7eeeb84f32': { + aggregators: ['rubic', 'rango'], + assetId: 'eip155:56/erc20:0x0400ff00ffd395ef93e701ae27087a7eeeb84f32', + decimals: 18, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x0400ff00ffd395ef93e701ae27087a7eeeb84f32.png', + name: 'ZooBit.Org', + occurrences: 2, + storage: { + approval: 2, + balance: 1, + }, + symbol: 'ZB', + isContractVerified: true, + }, + 'eip155:56/erc20:0x5ca42204cdaa70d5c773946e69de942b85ca6706': { + aggregators: ['pancakeCoinMarketCap', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0x5ca42204cdaa70d5c773946e69de942b85ca6706', + decimals: 18, + description: { + en: "Position Exchange is the new Decentralized Trading Protocol, powered by a vAMM and operating on Binance Smart Chain initially, aiming to bridge the gap between people and the cryptocurrency markets and enhance trading experiences.The protocol offers easy and accessible Derivatives Trading in which users can trade Crypto Derivatives Products fully on-chain transparently and trustless, with high security, and privacy with a plan to expand into other assets in the future. The platform is designed to deliver all the advantages of Decentralized Finance whilst bringing the traditional Centralized Finance experience and tools onboard. To mention High leverage, low slippage, and low costs as well as limit orders all while solving the liquidity issue using the vAMM.Moreover, Position Exchange's team designed a user-friendly and attractive interface allowing traders of all kinds to trade with ease. The platform is empowered by the POSI token, its native deflationary utility token serving as the backbone of its Ecosystem. Holders can benefit from multiple advantages and use POSI in the different developed features. Holders can Stake, Farm, and Cast NFTs to grow their POSI balance as well as participate in Position Exchange's governance and shape its future.", + }, + erc20Permit: false, + fees: { + avgFee: 0.9999996111545646, + maxFee: 0.9999999847496907, + minFee: 0.9999992375599904, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x5ca42204cdaa70d5c773946e69de942b85ca6706.png', + name: 'Position', + occurrences: 4, + storage: { + balance: 5, + approval: 3, + }, + symbol: 'POSI', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xa0aebd4ae5f256b72b7d43f67ed934237adb1aee': { + aggregators: ['coinGecko', 'rubic', 'rango'], + assetId: 'eip155:8453/erc20:0xa0aebd4ae5f256b72b7d43f67ed934237adb1aee', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'BONSAI COIN', + occurrences: 3, + storage: { + approval: 6, + balance: 5, + }, + symbol: 'BONSAICOIN', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xf8a99f2bf2ce5bb6ce4aafcf070d8723bc904aa2': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0xf8a99f2bf2ce5bb6ce4aafcf070d8723bc904aa2', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Chinese Brett', + occurrences: 4, + storage: { + approval: 9, + balance: 8, + }, + symbol: 'CHRETT', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x174e33ef2effa0a4893d97dda5db4044cc7993a3': { + aggregators: ['rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x174e33ef2effa0a4893d97dda5db4044cc7993a3', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Keren', + occurrences: 3, + storage: { + approval: 2, + balance: 1, + }, + symbol: 'KEREN', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x1b6a569dd61edce3c383f6d565e2f79ec3a12980': { + aggregators: ['metamask', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x1b6a569dd61edce3c383f6d565e2f79ec3a12980', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Young Peezy AKA Pepe', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'PEEZY', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xe4fcf2d991505089bbb36275570757c1f9800cb0': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0xe4fcf2d991505089bbb36275570757c1f9800cb0', + decimals: 18, + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + name: 'Purrcoin', + occurrences: 4, + storage: {}, + symbol: 'PURR', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x623cd3a3edf080057892aaf8d773bbb7a5c9b6e9': { + aggregators: ['coinGecko', 'liFi', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x623cd3a3edf080057892aaf8d773bbb7a5c9b6e9', + decimals: 18, + description: { + en: 'Sekuya is a video game company headquartered in Singapore. Born from a community, Sekuya aims to revolutionize the gaming landscape with a community-driven approach in all new anime epic fantasy universe. Sekuya’s flagship project, Sekuya Multiverse, an award-winning start-up project, combines 2 of the world’s most popular gaming genres: MOBA + RPG, promising a new gaming experience for global players of both genres. Problem: The current fast-growing MOBA gaming genres have not received a significant gameplay update since around 2003. Additionally, despite the total gaming revenue reaching $180 billion in 2023, game item ownership remains centralized. Approach: An entirely new genre of Epic Fantasy MOBA MMORPG powered by a unique Web3 ownership (heroes, items, skills, pets) and AI co-creation tools (user generated skin & personalized superpower). Positioning: We are among the pioneers in introducing a completely unique gameplay experience, along with Web3 ownership and AI co-creation tools that have the potential to appeal to millions of gamers and creators. Sekuya Multiverse, an award-winning start up project with GAMEFI AI RWA narrative, combines 2 of the world’s most favorite gaming genres: MOBA MMORPG, promising a new experience for 250 million global players Supported by over 100 communities in Southeast Asia, Sekuya Multiverse offers an immersive MMORPG experience set in the Novae Terrae, a 10-world universe. Players, known as "Jumpers," can utilize an AI character creator to customize their own character, interact with AI NPCs, embark on engaging storylines, and participate in battles to collect 400+ sekumon souls and win the grand rewards. Anticipate an exhilarating 5v5 MOBA featuring unique superpowers bestowed by Sekuya heroes and special abilities tailored to each player\'s personality.', + }, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Sekuya Multiverse', + occurrences: 5, + storage: { + approval: 2, + balance: 1, + }, + symbol: 'SKYA', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x40e3eddf6d253bb734381a309437428f121c594b': { + aggregators: ['rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x40e3eddf6d253bb734381a309437428f121c594b', + decimals: 18, + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + name: 'Larva Lads', + occurrences: 3, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'LAD', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x6223901ea64608c75da8497d5eff15d19a1d8fd5': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x6223901ea64608c75da8497d5eff15d19a1d8fd5', + decimals: 18, + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + name: 'Corgi', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'CORGI', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xcde172dc5ffc46d228838446c57c1227e0b82049': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0xcde172dc5ffc46d228838446c57c1227e0b82049', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Boomer', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'BOOMER', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xe3086852a4b125803c815a158249ae468a3254ca': { + aggregators: ['coinGecko', 'liFi', 'rubic', 'squid', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0xe3086852a4b125803c815a158249ae468a3254ca', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'mfercoin', + occurrences: 6, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'MFER', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x9de16c805a3227b9b92e39a446f9d56cf59fe640': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x9de16c805a3227b9b92e39a446f9d56cf59fe640', + decimals: 18, + description: { + en: 'A Dog Meme Coin On Base', + }, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x9de16c805a3227b9b92e39a446f9d56cf59fe640.png', + name: 'Bento', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'BENTO', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x2c001233ed5e731b98b15b30267f78c7560b71f2': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x2c001233ed5e731b98b15b30267f78c7560b71f2', + decimals: 18, + name: 'BUBU', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'BUBU', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { + aggregators: [ + 'coinGecko', + 'oneInch', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + decimals: 6, + erc20Permit: true, + honeypotStatus: {}, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.png', + labels: ['badges:v1:stablecoin'], + name: 'USD Coin', + occurrences: 8, + storage: { + approval: 10, + balance: 9, + }, + symbol: 'USDC', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xb8d98a102b0079b69ffbc760c8d857a31653e56e': { + aggregators: [ + 'coinGecko', + 'oneInch', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:8453/erc20:0xb8d98a102b0079b69ffbc760c8d857a31653e56e', + decimals: 18, + description: { + en: 'cute frog community project airdropped to entire base community', + }, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'toby', + occurrences: 7, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'TOBY', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x07d15798a67253d76cea61f0ea6f57aedc59dffb': { + aggregators: ['coinGecko', 'rubic', 'rango'], + assetId: 'eip155:8453/erc20:0x07d15798a67253d76cea61f0ea6f57aedc59dffb', + decimals: 18, + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + name: 'Based Coin', + occurrences: 3, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'BASED', + isContractVerified: true, + }, + 'eip155:42161/erc20:0x912ce59144191c1204e64559fe8253a0e49e6548': { + aggregators: [ + 'traderJoe', + 'oneInch', + 'liFi', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:42161/erc20:0x912ce59144191c1204e64559fe8253a0e49e6548', + decimals: 18, + description: { + en: 'Arbitrum is one of the leading Ethereum scaling solutions bringing cheap transactions to tens of thousands of users in an environment that feels very similar to Ethereum. It is an optimistic rollup and the leading L2 in terms of TVL. Some of the largest dApps live on Arbitrum include GMX, Radiant, Uniswap V3, and Gains Network.', + }, + erc20Permit: true, + honeypotStatus: {}, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/42161/erc20/0x912ce59144191c1204e64559fe8253a0e49e6548.png', + name: 'Arbitrum', + occurrences: 9, + storage: { + balance: 51, + approval: 52, + }, + symbol: 'ARB', + isContractVerified: true, + }, + 'eip155:143/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da': { + aggregators: ['dynamic'], + assetId: 'eip155:143/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + labels: ['badges:v1:stablecoin'], + name: 'MetaMask USD', + storage: { + approvalStr: + '92155457228465093955173560380360064720849401111993098342695494701049963192576', + balanceStr: + '107734271257630865975065146523144289492045861028913371777218889022146465165570', + }, + symbol: 'mUSD', + }, + 'eip155:42161/erc20:0x539bde0d7dbd336b79148aa742883198bbf60342': { + aggregators: [ + 'traderJoe', + 'oneInch', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:42161/erc20:0x539bde0d7dbd336b79148aa742883198bbf60342', + decimals: 18, + erc20Permit: true, + honeypotStatus: {}, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/42161/erc20/0x539bde0d7dbd336b79148aa742883198bbf60342.png', + name: 'MAGIC', + occurrences: 8, + storage: { + approval: 52, + balance: 51, + }, + symbol: 'MAGIC', + isContractVerified: true, + }, + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831': { + aggregators: [ + 'traderJoe', + 'oneInch', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831', + decimals: 6, + description: { + en: 'USDC is a fully collateralized US dollar stablecoin. USDC is the bridge between dollars and trading on cryptocurrency exchanges. The technology behind CENTRE makes it possible to exchange value between people, businesses and financial institutions just like email between mail services and texts between SMS providers. We believe by removing artificial economic borders, we can create a more inclusive global economy.', + }, + erc20Permit: true, + honeypotStatus: {}, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/42161/erc20/0xaf88d065e77c8cc2239327c5edb3a432268e5831.png', + labels: ['badges:v1:stablecoin'], + name: 'USD Coin (Native)', + occurrences: 8, + storage: { + approval: 10, + balance: 9, + }, + symbol: 'USDC', + isContractVerified: true, + }, + 'eip155:1/erc20:0x2b591e99afe9f32eaa6214f7b7629768c40eeb39': { + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'trustWallet', + 'rubic', + 'rango', + 'sonarwatch', + ], + assetId: 'eip155:1/erc20:0x2b591e99afe9f32eaa6214f7b7629768c40eeb39', + decimals: 8, + description: { + en: 'Launched on December 2, 2019 by Richard Heart and team, HEX is the first certificate of deposit on the blockchain, essentially time deposits that gain interest, HEX is an ERC20 Token that runs over the Ethereum network', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x2b591e99afe9f32eaa6214f7b7629768c40eeb39.png', + name: 'HEX', + occurrences: 7, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'HEX', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x653a143b8d15c565c6623d1f168cfbec1056d872': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x653a143b8d15c565c6623d1f168cfbec1056d872', + decimals: 9, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x653a143b8d15c565c6623d1f168cfbec1056d872.png', + name: 'kurbi', + occurrences: 4, + storage: { + approval: 2, + balance: 1, + }, + symbol: 'KURBI', + isContractVerified: true, + }, + 'eip155:10/erc20:0x4200000000000000000000000000000000000042': { + aggregators: [ + 'uniswap', + 'oneInch', + 'liFi', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000042', + decimals: 18, + description: { + en: "OP is the token for the Optimism Collective that governs the Optimism L2 blockchain. The Optimism Collective is a large-scale experiment in digital democratic governance, built to drive rapid and sustainable growth of a decentralized ecosystem, and stewarded by the newly formed Optimism Foundation.OP governs upgrades to the protocol and network parameters, and creates an ongoing system of incentives for projects and users in the Optimism ecosystem. 5.4% of the total token supply will be distributed to projects on Optimism over the next six months via governance. If you're building something in the Ethereum ecosystem, you can consider applying for the grant.", + }, + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0x4200000000000000000000000000000000000042.png', + name: 'Optimism', + occurrences: 11, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'OP', + isContractVerified: false, + }, + 'eip155:8453/erc20:0xba71cb8ef2d59de7399745793657838829e0b147': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0xba71cb8ef2d59de7399745793657838829e0b147', + decimals: 18, + description: { + en: 'One of the first community owned Meme tokens on the base chain! ', + }, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0xba71cb8ef2d59de7399745793657838829e0b147.png', + name: 'Siamese', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'SIAM', + isContractVerified: true, + }, + 'eip155:10/erc20:0x4b03afc91295ed778320c2824bad5eb5a1d852dd': { + aggregators: ['rubic', 'rango'], + assetId: 'eip155:10/erc20:0x4b03afc91295ed778320c2824bad5eb5a1d852dd', + decimals: 18, + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + name: 'NBL', + occurrences: 2, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'NBL', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xfb18511f1590a494360069f3640c27d55c2b5290': { + aggregators: ['rubic', 'rango'], + assetId: 'eip155:8453/erc20:0xfb18511f1590a494360069f3640c27d55c2b5290', + decimals: 6, + erc20Permit: true, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0xfb18511f1590a494360069f3640c27d55c2b5290.png', + name: 'Wild Goat Coin', + occurrences: 2, + storage: { + approval: 6, + balance: 5, + }, + symbol: 'WGC', + isContractVerified: true, + }, + 'eip155:59144/erc20:0x374d7860c4f2f604de0191298dd393703cce84f3': { + aggregators: ['metamask', 'oneInch', 'liFi', 'rubic', 'rango'], + assetId: 'eip155:59144/erc20:0x374d7860c4f2f604de0191298dd393703cce84f3', + decimals: 6, + name: 'Aave v3 USDC', + occurrences: 5, + storage: { + approval: 53, + }, + symbol: 'AUSDC', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x4d58608eff50b691a3b76189af2a7a123df1e9ba': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x4d58608eff50b691a3b76189af2a7a123df1e9ba', + decimals: 9, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Boysclubbase', + occurrences: 4, + storage: { + approval: 2, + balance: 1, + }, + symbol: '$BOYS', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xff62ddfa80e513114c3a0bf4d6ffff1c1d17aadf': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0xff62ddfa80e513114c3a0bf4d6ffff1c1d17aadf', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Boe', + occurrences: 4, + storage: { + approval: 8, + balance: 7, + }, + symbol: 'BOE', + isContractVerified: true, + }, + 'eip155:10/erc20:0xecf46257ed31c329f204eb43e254c609dee143b3': { + aggregators: ['uniswap', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:10/erc20:0xecf46257ed31c329f204eb43e254c609dee143b3', + decimals: 18, + description: { + en: '"RigoBlock exists to reinvent the asset management industry, making it possible for anyone, anywhere, to set up and manage decentralized token pools which combine the powers of transparency, control, flexibility and governance. By virtue of its modular architecture, developers can build their own distributed asset management platforms atop of the RigoBlock protocol and leverage the unique technology made available by RigoBlock protocol and the Rigo Token (‘GRG’) incentives mechanism. Through the creation of a revolutionary Proof-of-Performance incentive algorithm, RigoBlock removes the need for antiquated management fees to facilitate a new generation of asset management - one built around trust, transparency and simplicity."', + }, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0xecf46257ed31c329f204eb43e254c609dee143b3.png', + name: 'RigoBlock', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'GRG', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xb56d0839998fd79efcd15c27cf966250aa58d6d3': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0xb56d0839998fd79efcd15c27cf966250aa58d6d3', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Based USA', + occurrences: 4, + storage: { + approval: 2, + balance: 1, + }, + symbol: 'USA', + isContractVerified: true, + }, + 'eip155:59144/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da': { + aggregators: ['metamask', 'oneInch', 'liFi', 'rubic', 'squid', 'rango'], + assetId: 'eip155:59144/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + labels: ['badges:v1:stablecoin'], + name: 'MetaMask USD', + occurrences: 6, + storage: { + approvalStr: + '92155457228465093955173560380360064720849401111993098342695494701049963192576', + balanceStr: + '107734271257630865975065146523144289492045861028913371777218889022146465165570', + }, + symbol: 'MUSD', + isContractVerified: true, + }, + 'eip155:4663/erc20:0x5fc5360d0400a0fd4f2af552add042d716f1d168': { + aggregators: ['metamask', 'oneInch', 'liFi'], + assetId: 'eip155:4663/erc20:0x5fc5360d0400a0fd4f2af552add042d716f1d168', + decimals: 6, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/4663/erc20/0x5fc5360d0400a0fd4f2af552add042d716f1d168.png', + name: 'Global Dollar', + occurrences: 3, + storage: { + approval: 3, + balance: 1, + }, + symbol: 'USDG', + }, + 'eip155:8453/erc20:0x9aaae745cf2830fb8ddc6248b17436dc3a5e701c': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:8453/erc20:0x9aaae745cf2830fb8ddc6248b17436dc3a5e701c', + decimals: 18, + description: { + en: "Gochujangcoin draws its inspiration from the renowned Korean condiment, Gochujang, and plans to expand its reach through related games, NFTs, and K-food recipes. The innovative 'Tap to Earn' games offer users new culinary experiences and encourage active participation, positioning Gochujangcoin as more substantive than typical investment tokens. It fosters an active community through K-food recipes, rewarding engagement with tokens and offering unique K-food-themed NFTs, blending culinary heritage with blockchain technology.", + }, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x9aaae745cf2830fb8ddc6248b17436dc3a5e701c.png', + name: 'Gochujangcoin', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'GOCHU', + isContractVerified: true, + }, + 'eip155:1/erc20:0x82866b4a71ba9d930fe338c386b6a45a7133eb36': { + aggregators: ['coinMarketCap', 'rubic'], + assetId: 'eip155:1/erc20:0x82866b4a71ba9d930fe338c386b6a45a7133eb36', + decimals: 9, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: true, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x82866b4a71ba9d930fe338c386b6a45a7133eb36.png', + name: 'QCORE.FINANCE', + occurrences: 2, + storage: { + balance: 3, + approval: 4, + }, + symbol: 'QCORE', + isContractVerified: true, + }, + 'eip155:137/erc20:0x9e2d266d6c90f6c0d80a88159b15958f7135b8af': { + aggregators: ['rubic'], + assetId: 'eip155:137/erc20:0x9e2d266d6c90f6c0d80a88159b15958f7135b8af', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/0x9e2d266d6c90f6c0d80a88159b15958f7135b8af.png', + name: 'StakeShare', + occurrences: 1, + storage: { + balance: 3, + approval: 1, + }, + symbol: 'SSX', + isContractVerified: true, + }, + 'eip155:137/erc20:0x3c0bd2118a5e61c41d2adeebcb8b7567fde1cbaf': { + aggregators: ['rubic'], + assetId: 'eip155:137/erc20:0x3c0bd2118a5e61c41d2adeebcb8b7567fde1cbaf', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/0x3c0bd2118a5e61c41d2adeebcb8b7567fde1cbaf.png', + name: 'Cookie', + occurrences: 1, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'CKIE', + isContractVerified: true, + }, + 'eip155:1/erc20:0x43e6228b5bf22eab754486082ca91fdd8585521a': { + aggregators: ['coinMarketCap', 'rubic'], + assetId: 'eip155:1/erc20:0x43e6228b5bf22eab754486082ca91fdd8585521a', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x43e6228b5bf22eab754486082ca91fdd8585521a.png', + name: 'DIXT.FINANCE', + occurrences: 2, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'DIXT', + isContractVerified: true, + }, + 'eip155:1/erc20:0x6051c1354ccc51b4d561e43b02735deae64768b8': { + aggregators: ['coinMarketCap', 'rubic'], + assetId: 'eip155:1/erc20:0x6051c1354ccc51b4d561e43b02735deae64768b8', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x6051c1354ccc51b4d561e43b02735deae64768b8.png', + name: 'yRise.Finance', + occurrences: 2, + storage: { + balance: 4, + approval: 5, + }, + symbol: 'YRISE', + isContractVerified: true, + }, + 'eip155:1/erc20:0x5380442d3c4ec4f5777f551f5edd2fa0f691a27c': { + aggregators: ['coinMarketCap', 'rubic'], + assetId: 'eip155:1/erc20:0x5380442d3c4ec4f5777f551f5edd2fa0f691a27c', + decimals: 18, + description: { + en: '$LOVE token was donated to everyone who donated to the UkraineDAO Party Bid and for everyone who donated directly to the ukrainedao.eth prior to the snapshot on Mar 3. The $LOVE token is a symbol, not a utility, commemorating the donor’s contribution. Seeing these tokens or other POAPs in one’s wallet reminds people of the bigger picture behind Web3 building and decentralized organizations.', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x5380442d3c4ec4f5777f551f5edd2fa0f691a27c.png', + name: 'UkraineDAO Flag NFT', + occurrences: 2, + storage: { + balance: 51, + approval: 52, + }, + symbol: 'LOVE', + isContractVerified: true, + }, + 'eip155:10/erc20:0xad984fbd3fb10d0b47d561be7295685af726fdb3': { + aggregators: ['rubic'], + assetId: 'eip155:10/erc20:0xad984fbd3fb10d0b47d561be7295685af726fdb3', + decimals: 18, + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + honeypotStatus: { + goPlus: false, + }, + name: 'LARRY TALBOT', + occurrences: 1, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'LARRY', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x160452f95612699d1a561a70eeeeede67c6812af': { + aggregators: ['rango'], + assetId: 'eip155:8453/erc20:0x160452f95612699d1a561a70eeeeede67c6812af', + decimals: 18, + description: { + en: 'BORD is an original memecoin project aimed at bringing more users to the Base chain with its fun, clever, and nostalgic memes. BORD has a strong community focus that strives to show old and new crypto enthusiasts the power of the based side, with the help of its rich lore and storytelling.', + }, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x160452f95612699d1a561a70eeeeede67c6812af.png', + name: 'Base Lord', + occurrences: 1, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'BORD', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x41e357ea17eed8e3ee32451f8e5cba824af58dbf': { + aggregators: ['rubic'], + assetId: 'eip155:8453/erc20:0x41e357ea17eed8e3ee32451f8e5cba824af58dbf', + decimals: 18, + name: 'Coinbase Wrapped XRP', + occurrences: 1, + symbol: 'CBXRP', + }, + 'eip155:8453/erc20:0x9a27c6759a6de0f26ac41264f0856617dec6bc3f': { + aggregators: ['rubic', 'rango'], + assetId: 'eip155:8453/erc20:0x9a27c6759a6de0f26ac41264f0856617dec6bc3f', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Monkey Peepo', + occurrences: 2, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'BANANAS', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x80e3ee7bab68feaea6e01c44df9daa5de53e4818': { + aggregators: ['rubic'], + assetId: 'eip155:8453/erc20:0x80e3ee7bab68feaea6e01c44df9daa5de53e4818', + decimals: 9, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'DOGECOIN', + occurrences: 1, + storage: { + balance: 1, + approval: 2, + }, + symbol: 'DOGE', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xafb5d4d474693e68df500c9c682e6a2841f9661a': { + aggregators: ['rango'], + assetId: 'eip155:8453/erc20:0xafb5d4d474693e68df500c9c682e6a2841f9661a', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Bloomer', + occurrences: 1, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'BLOOM', + isContractVerified: true, + }, + 'eip155:8453/erc20:0xc5a861787f3e173f2b004d5cfa6a717f5dc5484d': { + aggregators: ['rubic'], + assetId: 'eip155:8453/erc20:0xc5a861787f3e173f2b004d5cfa6a717f5dc5484d', + decimals: 18, + name: 'Snow Leopard', + occurrences: 1, + symbol: 'SNL', + }, + 'eip155:8453/erc20:0x478e03d45716dda94f6dbc15a633b0d90c237e2f': { + aggregators: ['rubic', 'rango'], + assetId: 'eip155:8453/erc20:0x478e03d45716dda94f6dbc15a633b0d90c237e2f', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Shaka', + occurrences: 2, + storage: { + approval: 1, + balance: 0, + }, + symbol: '$SHAKA', + isContractVerified: true, + }, + 'eip155:10/erc20:0x67631ff69130ea1a6c4feaa4a0abf0a1e0148be7': { + aggregators: ['rubic', 'rango'], + assetId: 'eip155:10/erc20:0x67631ff69130ea1a6c4feaa4a0abf0a1e0148be7', + decimals: 6, + description: { + en: 'Memecoin / Digital Collectible', + }, + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0x67631ff69130ea1a6c4feaa4a0abf0a1e0148be7.png', + name: 'Wild Goat Coin', + occurrences: 2, + storage: { + balance: 5, + approval: 6, + }, + symbol: 'WGC', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x4f6e6b8efc7cfb23dbd53c1b09f7389ef8191693': { + aggregators: ['rubic'], + assetId: 'eip155:8453/erc20:0x4f6e6b8efc7cfb23dbd53c1b09f7389ef8191693', + decimals: 18, + honeypotStatus: { + goPlus: false, + }, + name: 'AI Love Meme', + occurrences: 1, + symbol: 'AILM', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x340c070260520ae477b88caa085a33531897145b': { + aggregators: ['rubic'], + assetId: 'eip155:8453/erc20:0x340c070260520ae477b88caa085a33531897145b', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + name: 'Shigure UI', + occurrences: 1, + storage: { + balance: 7, + approval: 8, + }, + symbol: '9MM', + isContractVerified: true, + }, + 'eip155:8453/erc20:0x491b67a94ec0a59b81b784f4719d0387c4510c36': { + aggregators: ['rubic', 'rango'], + assetId: 'eip155:8453/erc20:0x491b67a94ec0a59b81b784f4719d0387c4510c36', + decimals: 18, + name: 'Purple Frog', + occurrences: 2, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'PF', + isContractVerified: true, + }, +} as const; + +export default v3Assets; diff --git a/packages/assets-controller/src/__fixtures__/scam-token-cleanup/captureTokenApiResponses.ts b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/captureTokenApiResponses.ts new file mode 100644 index 00000000000..112e71dec3c --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/captureTokenApiResponses.ts @@ -0,0 +1,83 @@ +import { API_URLS } from '@metamask/core-backend'; +import { KnownCaipNamespace, parseCaipAssetType } from '@metamask/utils'; +import { writeFileSync } from '@metamask/utils/node'; + +import { SCAM_WALLET_ASSETS_INFO } from './scamWalletState.js'; + +const OUT_DIR = new URL('./api-responses/', import.meta.url); +const BATCH_SIZE = 50; +const V3_ASSETS_QUERY = { + includeIconUrl: 'true', + includeMarketData: 'true', + includeMetadata: 'true', + includeLabels: 'true', + includeRwaData: 'true', + includeAggregators: 'true', + includeOccurrences: 'true', +} as const; + +function sweepableAssetIds(): string[] { + return Object.keys(SCAM_WALLET_ASSETS_INFO).filter((assetId) => { + try { + const { assetNamespace, chain } = parseCaipAssetType( + assetId as `${string}:${string}/${string}:${string}`, + ); + return ( + chain.namespace === KnownCaipNamespace.Eip155 && + assetNamespace === 'erc20' + ); + } catch { + return false; + } + }); +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${url}`); + } + return response.json(); +} + +async function main(): Promise { + const floors = await fetchJson( + `${API_URLS.TOKEN}/v1/suggestedOccurrenceFloors`, + ); + const assetIds = sweepableAssetIds(); + const assets: Record = {}; + + for (let i = 0; i < assetIds.length; i += BATCH_SIZE) { + const batch = assetIds.slice(i, i + BATCH_SIZE); + const params = new URLSearchParams({ + ...V3_ASSETS_QUERY, + assetIds: batch.join(','), + }); + const entries = (await fetchJson( + `${API_URLS.TOKENS}/v3/assets?${params.toString()}`, + )) as { assetId?: string }[]; + for (const entry of entries) { + if (entry.assetId) { + assets[entry.assetId] = entry; + } + } + } + + writeFileSync( + new URL('token-api/suggestedOccurrenceFloors.ts', OUT_DIR), + `const suggestedOccurrenceFloors = ${JSON.stringify(floors, null, 2)} as const;\n\nexport default suggestedOccurrenceFloors;\n`, + ); + writeFileSync( + new URL('tokens-api/v3-assets.ts', OUT_DIR), + `const v3Assets = ${JSON.stringify(assets, null, 2)} as const;\n\nexport default v3Assets;\n`, + ); + + console.log( + `Captured ${Object.keys(assets).length} assets across ${assetIds.length} sweepable IDs.`, + ); +} + +main().catch((error) => { + console.error(error); + throw error; +}); diff --git a/packages/assets-controller/src/__fixtures__/scam-token-cleanup/scamWalletState.ts b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/scamWalletState.ts new file mode 100644 index 00000000000..142cc738453 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/scam-token-cleanup/scamWalletState.ts @@ -0,0 +1,1645 @@ +import type { + AssetMetadata, + AssetsControllerStateInternal, + Caip19AssetId, +} from '../../types.js'; + +/** + * Example state for the scam-token cleanup integration test + * (`AssetsController.spam-cleanup.integration.test.ts`). + * + * Captured from a production state log (`MetaMask_state_logs__2_.json`): a + * heavily airdropped wallet holding 83 assets across EVM, Solana, Bitcoin, + * Tron and Stellar, with the usual Base airdrop scam tokens sitting next to + * genuine holdings. Only the slices the spam sweep reads are kept: + * `assetsInfo` (what is tracked), `customAssets` (the hand-imported Arbitrum + * USDC that must survive) and the native asset map. Balances are derived + * per-account so swept scam tokens also leave balances. + */ + +export const SCAM_WALLET_ACCOUNT_ID = 'spam-cleanup-account'; +export const SCAM_WALLET_ACCOUNT_ADDRESS = + '0x7950fb33c6ca289446feeefae14fc156741f93ac'; + +/** The hand-imported token, keyed by its real owning account id. */ +export const SCAM_WALLET_CUSTOM_ASSETS = { + '980769e0-a280-4ea1-b98a-71a35026bbb1': [ + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as const, + ], +}; + +/** Metadata for all 83 assets, verbatim from the state log. */ +export const SCAM_WALLET_ASSETS_INFO = { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + aggregators: ['metamask'], + decimals: 8, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/bip122/000000000019d6689c085ae165831e93/slip44/0.png', + name: 'Bitcoin', + occurrences: 100, + symbol: 'BTC', + type: 'native', + }, + 'eip155:1/erc20:0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39': { + decimals: 8, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x2b591e99afe9f32eaa6214f7b7629768c40eeb39.png', + name: 'HEX', + symbol: 'HEX', + type: 'erc20', + }, + 'eip155:1/erc20:0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8': { + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x4d5f47fa6a74757f35c14fd3a6ef8e3c9bc514e8.png', + name: 'Aave v3 WETH', + symbol: 'AWETH', + type: 'erc20', + }, + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'trustWallet', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'bancor', + ], + decimals: 6, + description: { + en: 'USDC is a fully collateralized US dollar stablecoin. USDC is the bridge between dollars and trading on cryptocurrency exchanges. The technology behind CENTRE makes it possible to exchange value between people, businesses and financial institutions just like email between mail services and texts between SMS providers. We believe by removing artificial economic borders, we can create a more inclusive global economy.', + }, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + isContractVerified: true, + labels: ['stable_coin', 'badges:v1:stablecoin'], + name: 'USDC', + occurrences: 10, + storage: { + approval: 10, + balance: 9, + }, + symbol: 'USDC', + type: 'erc20', + }, + 'eip155:1/erc20:0xA700b4eB416Be35b2911fd5Dee80678ff64fF6C9': { + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xa700b4eb416be35b2911fd5dee80678ff64ff6c9.png', + name: 'Aave v3 AAVE', + symbol: 'AAAVE', + type: 'erc20', + }, + 'eip155:1/erc20:0xAa0200d169fF3ba9385c12E073c5d1d30434AE7b': { + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xaa0200d169ff3ba9385c12e073c5d1d30434ae7b.png', + name: 'Aave v3 mUSD', + symbol: 'AMUSD', + type: 'erc20', + }, + 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA': { + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + name: 'MetaMask USD', + symbol: 'MUSD', + type: 'erc20', + }, + 'eip155:1/slip44:60': { + type: 'native', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + occurrences: 100, + aggregators: [], + erc20Permit: false, + honeypotStatus: {}, + isContractVerified: false, + description: { + en: 'Ethereum is a global, open-source platform for decentralized applications. In other words, the vision is to create a world computer that anyone can build applications in a decentralized manner; while all states and data are distributed and publicly accessible. Ethereum supports smart contracts in which developers can write code in order to program digital value. Examples of decentralized apps (dapps) that are built on Ethereum includes tokens, non-fungible tokens, decentralized finance apps, lending protocol, decentralized exchanges, and much more.On Ethereum, all transactions and smart contract executions require a small fee to be paid. This fee is called Gas. In technical terms, Gas refers to the unit of measure on the amount of computational effort required to execute an operation or a smart contract. The more complex the execution operation is, the more gas is required to fulfill that operation. Gas fees are paid entirely in Ether (ETH), which is the native coin of the blockchain. The price of gas can fluctuate from time to time depending on the network demand.', + ko: '이더리움(Ethereum/ETH)은 블록체인 기술에 기반한 클라우드 컴퓨팅 플랫폼 또는 프로그래밍 언어이다. 비탈릭 부테린이 개발하였다.비탈릭 부테린은 가상화폐인 비트코인에 사용된 핵심 기술인 블록체인(blockchain)에 화폐 거래 기록뿐 아니라 계약서 등의 추가 정보를 기록할 수 있다는 점에 착안하여, 전 세계 수많은 사용자들이 보유하고 있는 컴퓨팅 자원을 활용해 분산 네트워크를 구성하고, 이 플랫폼을 이용하여 SNS, 이메일, 전자투표 등 다양한 정보를 기록하는 시스템을 창안했다. 이더리움은 C++, 자바, 파이썬, GO 등 주요 프로그래밍 언어를 지원한다.이더리움을 사물 인터넷(IoT)에 적용하면 기계 간 금융 거래도 가능해진다. 예를 들어 고장난 청소로봇이 정비로봇에 돈을 내고 정비를 받고, 청소로봇은 돈을 벌기 위해 정비로봇의 집을 청소하는 것도 가능해진다.', + zh: 'Ethereum(以太坊)是一个平台和一种编程语言,使开发人员能够建立和发布下一代分布式应用。Ethereum 是使用甲醚作为燃料,以激励其网络的第一个图灵完备cryptocurrency。Ethereum(以太坊) 是由Vitalik Buterin的创建。该项目于2014年8月获得了美国1800万$比特币的价值及其crowdsale期间。在2016年,Ethereum(以太坊)的价格上涨超过50倍。', + ja: 'イーサリアム (Ethereum, ETH)・プロジェクトにより開発が進められている、分散型アプリケーション(DApps)やスマート・コントラクトを構築するためのプラットフォームの名称、及び関連するオープンソース・ソフトウェア・プロジェクトの総称である。イーサリアムでは、イーサリアム・ネットワークと呼ばれるP2Pのネットワーク上でスマート・コントラクトの履行履歴をブロックチェーンに記録していく。またイーサリアムは、スマート・コントラクトを記述するチューリング完全なプログラミング言語を持ち、ネットワーク参加者はこのネットワーク上のブロックチェーンに任意のDAppsやスマート・コントラクトを記述しそれを実行することが可能になる。ネットワーク参加者が「Ether」と呼ばれるイーサリアム内部通貨の報酬を目当てに、採掘と呼ばれるブロックチェーンへのスマート・コントラクトの履行結果の記録を行うことで、その正統性を保証していく。このような仕組みにより特定の中央管理組織に依拠せず、P2P全体を実行環境としてプログラムの実行とその結果を共有することが可能になった。', + }, + }, + 'eip155:10/slip44:60': { + type: 'native', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/slip44/60.png', + occurrences: 100, + aggregators: [], + erc20Permit: false, + honeypotStatus: {}, + isContractVerified: false, + description: { + en: 'Ethereum is a global, open-source platform for decentralized applications. In other words, the vision is to create a world computer that anyone can build applications in a decentralized manner; while all states and data are distributed and publicly accessible. Ethereum supports smart contracts in which developers can write code in order to program digital value. Examples of decentralized apps (dapps) that are built on Ethereum includes tokens, non-fungible tokens, decentralized finance apps, lending protocol, decentralized exchanges, and much more.On Ethereum, all transactions and smart contract executions require a small fee to be paid. This fee is called Gas. In technical terms, Gas refers to the unit of measure on the amount of computational effort required to execute an operation or a smart contract. The more complex the execution operation is, the more gas is required to fulfill that operation. Gas fees are paid entirely in Ether (ETH), which is the native coin of the blockchain. The price of gas can fluctuate from time to time depending on the network demand.', + ko: '이더리움(Ethereum/ETH)은 블록체인 기술에 기반한 클라우드 컴퓨팅 플랫폼 또는 프로그래밍 언어이다. 비탈릭 부테린이 개발하였다.비탈릭 부테린은 가상화폐인 비트코인에 사용된 핵심 기술인 블록체인(blockchain)에 화폐 거래 기록뿐 아니라 계약서 등의 추가 정보를 기록할 수 있다는 점에 착안하여, 전 세계 수많은 사용자들이 보유하고 있는 컴퓨팅 자원을 활용해 분산 네트워크를 구성하고, 이 플랫폼을 이용하여 SNS, 이메일, 전자투표 등 다양한 정보를 기록하는 시스템을 창안했다. 이더리움은 C++, 자바, 파이썬, GO 등 주요 프로그래밍 언어를 지원한다.이더리움을 사물 인터넷(IoT)에 적용하면 기계 간 금융 거래도 가능해진다. 예를 들어 고장난 청소로봇이 정비로봇에 돈을 내고 정비를 받고, 청소로봇은 돈을 벌기 위해 정비로봇의 집을 청소하는 것도 가능해진다.', + zh: 'Ethereum(以太坊)是一个平台和一种编程语言,使开发人员能够建立和发布下一代分布式应用。Ethereum 是使用甲醚作为燃料,以激励其网络的第一个图灵完备cryptocurrency。Ethereum(以太坊) 是由Vitalik Buterin的创建。该项目于2014年8月获得了美国1800万$比特币的价值及其crowdsale期间。在2016年,Ethereum(以太坊)的价格上涨超过50倍。', + ja: 'イーサリアム (Ethereum, ETH)・プロジェクトにより開発が進められている、分散型アプリケーション(DApps)やスマート・コントラクトを構築するためのプラットフォームの名称、及び関連するオープンソース・ソフトウェア・プロジェクトの総称である。イーサリアムでは、イーサリアム・ネットワークと呼ばれるP2Pのネットワーク上でスマート・コントラクトの履行履歴をブロックチェーンに記録していく。またイーサリアムは、スマート・コントラクトを記述するチューリング完全なプログラミング言語を持ち、ネットワーク参加者はこのネットワーク上のブロックチェーンに任意のDAppsやスマート・コントラクトを記述しそれを実行することが可能になる。ネットワーク参加者が「Ether」と呼ばれるイーサリアム内部通貨の報酬を目当てに、採掘と呼ばれるブロックチェーンへのスマート・コントラクトの履行結果の記録を行うことで、その正統性を保証していく。このような仕組みにより特定の中央管理組織に依拠せず、P2P全体を実行環境としてプログラムの実行とその結果を共有することが可能になった。', + }, + }, + 'eip155:137/slip44:966': { + type: 'native', + name: 'Polygon Ecosystem Token', + symbol: 'POL', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/slip44/966.png', + occurrences: 100, + aggregators: [], + erc20Permit: false, + honeypotStatus: {}, + }, + 'eip155:143/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA': { + decimals: 6, + name: 'MetaMask USD', + symbol: 'mUSD', + type: 'erc20', + }, + 'eip155:143/slip44:268435779': { + type: 'native', + name: 'Mon', + symbol: 'MON', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/143/slip44/268435779.png', + occurrences: 100, + aggregators: [], + }, + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831': { + aggregators: [ + 'traderJoe', + 'oneInch', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + decimals: 6, + description: { + en: 'USDC is a fully collateralized US dollar stablecoin. USDC is the bridge between dollars and trading on cryptocurrency exchanges. The technology behind CENTRE makes it possible to exchange value between people, businesses and financial institutions just like email between mail services and texts between SMS providers. We believe by removing artificial economic borders, we can create a more inclusive global economy.', + }, + erc20Permit: true, + honeypotStatus: {}, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/42161/erc20/0xaf88d065e77c8cc2239327c5edb3a432268e5831.png', + isContractVerified: true, + labels: ['badges:v1:stablecoin'], + name: 'USD Coin (Native)', + occurrences: 8, + storage: { + approval: 10, + balance: 9, + }, + symbol: 'USDC', + type: 'erc20', + }, + 'eip155:42161/slip44:60': { + type: 'native', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/42161/slip44/60.png', + occurrences: 100, + aggregators: [], + erc20Permit: false, + honeypotStatus: {}, + isContractVerified: false, + description: { + en: 'Ethereum is a global, open-source platform for decentralized applications. In other words, the vision is to create a world computer that anyone can build applications in a decentralized manner; while all states and data are distributed and publicly accessible. Ethereum supports smart contracts in which developers can write code in order to program digital value. Examples of decentralized apps (dapps) that are built on Ethereum includes tokens, non-fungible tokens, decentralized finance apps, lending protocol, decentralized exchanges, and much more.On Ethereum, all transactions and smart contract executions require a small fee to be paid. This fee is called Gas. In technical terms, Gas refers to the unit of measure on the amount of computational effort required to execute an operation or a smart contract. The more complex the execution operation is, the more gas is required to fulfill that operation. Gas fees are paid entirely in Ether (ETH), which is the native coin of the blockchain. The price of gas can fluctuate from time to time depending on the network demand.', + ko: '이더리움(Ethereum/ETH)은 블록체인 기술에 기반한 클라우드 컴퓨팅 플랫폼 또는 프로그래밍 언어이다. 비탈릭 부테린이 개발하였다.비탈릭 부테린은 가상화폐인 비트코인에 사용된 핵심 기술인 블록체인(blockchain)에 화폐 거래 기록뿐 아니라 계약서 등의 추가 정보를 기록할 수 있다는 점에 착안하여, 전 세계 수많은 사용자들이 보유하고 있는 컴퓨팅 자원을 활용해 분산 네트워크를 구성하고, 이 플랫폼을 이용하여 SNS, 이메일, 전자투표 등 다양한 정보를 기록하는 시스템을 창안했다. 이더리움은 C++, 자바, 파이썬, GO 등 주요 프로그래밍 언어를 지원한다.이더리움을 사물 인터넷(IoT)에 적용하면 기계 간 금융 거래도 가능해진다. 예를 들어 고장난 청소로봇이 정비로봇에 돈을 내고 정비를 받고, 청소로봇은 돈을 벌기 위해 정비로봇의 집을 청소하는 것도 가능해진다.', + zh: 'Ethereum(以太坊)是一个平台和一种编程语言,使开发人员能够建立和发布下一代分布式应用。Ethereum 是使用甲醚作为燃料,以激励其网络的第一个图灵完备cryptocurrency。Ethereum(以太坊) 是由Vitalik Buterin的创建。该项目于2014年8月获得了美国1800万$比特币的价值及其crowdsale期间。在2016年,Ethereum(以太坊)的价格上涨超过50倍。', + ja: 'イーサリアム (Ethereum, ETH)・プロジェクトにより開発が進められている、分散型アプリケーション(DApps)やスマート・コントラクトを構築するためのプラットフォームの名称、及び関連するオープンソース・ソフトウェア・プロジェクトの総称である。イーサリアムでは、イーサリアム・ネットワークと呼ばれるP2Pのネットワーク上でスマート・コントラクトの履行履歴をブロックチェーンに記録していく。またイーサリアムは、スマート・コントラクトを記述するチューリング完全なプログラミング言語を持ち、ネットワーク参加者はこのネットワーク上のブロックチェーンに任意のDAppsやスマート・コントラクトを記述しそれを実行することが可能になる。ネットワーク参加者が「Ether」と呼ばれるイーサリアム内部通貨の報酬を目当てに、採掘と呼ばれるブロックチェーンへのスマート・コントラクトの履行結果の記録を行うことで、その正統性を保証していく。このような仕組みにより特定の中央管理組織に依拠せず、P2P全体を実行環境としてプログラムの実行とその結果を共有することが可能になった。', + }, + }, + 'eip155:4663/slip44:60': { + type: 'native', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/4663/slip44/60.png', + occurrences: 1, + aggregators: [], + }, + 'eip155:5042/slip44:5042': { + decimals: 18, + name: 'USDC', + symbol: 'USDC', + type: 'native', + }, + 'eip155:534352/slip44:60': { + aggregators: [], + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/534352/slip44/60.png', + name: 'Ether', + occurrences: 100, + symbol: 'ETH', + type: 'native', + }, + 'eip155:56/erc20:0x5CA42204cDaa70d5c773946e69dE942b85CA6706': { + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/56/0x5ca42204cdaa70d5c773946e69de942b85ca6706.png', + name: 'Position', + symbol: 'POSI', + type: 'erc20', + }, + 'eip155:56/erc20:0x683e9dCf085E5efCc7925858aAcE94D4b8882024': { + decimals: 9, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/56/0x683e9dcf085e5efcc7925858aace94d4b8882024.png', + name: 'TangYuan', + symbol: 'TANGYUAN', + type: 'erc20', + }, + 'eip155:56/slip44:714': { + type: 'native', + name: 'Binance Coin', + symbol: 'BNB', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/slip44/714.png', + occurrences: 100, + aggregators: [], + erc20Permit: false, + honeypotStatus: {}, + isContractVerified: false, + description: { + en: 'Binance Coin is the cryptocurrency of the Binance platform. It is a trading platform exclusively for cryptocurrencies. The name "Binance" is a combination of binary and finance.Thus, the startup name shows that only cryptocurrencies can be traded against each other. It is not possible to trade crypto currencies against Fiat. The platform achieved an enormous success within a very short time and is focused on worldwide market with Malta headquarters. The cryptocurrency currently has a daily trading volume of 1.5 billion - 2 billion US dollars and is still increasing.In total, there will only be 200 million BNBs. Binance uses the ERC20 token standard from Ethereum and has distributed it as follow: 50% sold on ICO, 40% to the team and 10% to Angel investors. The coin can be used to pay fees on Binance. These include trading fees, transaction fees, listing fees and others. Binance gives you a huge discount when fees are paid in BNB. The schedule of BNB fees discount is as follow: In the first year, 50% discount on all fees, second year 25% discount, third year 12.5% discount, fourth year 6.75 % discount, and from the fifth year onwards there is no discount. This structure is used to incentivize users to buy BNB and do trades within Binance.Binance announced in a buyback plan that it would buy back up to 100 million BNB in Q1 2018. The coins are then burned. This means that they are devaluated to increase the value of the remaining coins. This benefits investors. In the future, the cryptocurrency will remain an asset on the trading platform and will be used as gas.Other tokens that are issued by exchanges include Bibox Token, OKB, Huobi Token, and more.', + }, + }, + 'eip155:59144/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA': { + type: 'erc20', + name: 'MetaMask USD', + symbol: 'MUSD', + decimals: 6, + occurrences: 6, + aggregators: ['metamask', 'oneInch', 'liFi', 'rubic', 'squid', 'rango'], + labels: ['badges:v1:stablecoin'], + storage: { + approvalStr: + '92155457228465093955173560380360064720849401111993098342695494701049963192576', + balanceStr: + '107734271257630865975065146523144289492045861028913371777218889022146465165570', + }, + isContractVerified: true, + }, + 'eip155:59144/slip44:60': { + type: 'native', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/59144/slip44/60.png', + occurrences: 1, + aggregators: [], + erc20Permit: false, + honeypotStatus: {}, + description: { + en: 'Ethereum is a global, open-source platform for decentralized applications. In other words, the vision is to create a world computer that anyone can build applications in a decentralized manner; while all states and data are distributed and publicly accessible. Ethereum supports smart contracts in which developers can write code in order to program digital value. Examples of decentralized apps (dapps) that are built on Ethereum includes tokens, non-fungible tokens, decentralized finance apps, lending protocol, decentralized exchanges, and much more.On Ethereum, all transactions and smart contract executions require a small fee to be paid. This fee is called Gas. In technical terms, Gas refers to the unit of measure on the amount of computational effort required to execute an operation or a smart contract. The more complex the execution operation is, the more gas is required to fulfill that operation. Gas fees are paid entirely in Ether (ETH), which is the native coin of the blockchain. The price of gas can fluctuate from time to time depending on the network demand.', + ko: '이더리움(Ethereum/ETH)은 블록체인 기술에 기반한 클라우드 컴퓨팅 플랫폼 또는 프로그래밍 언어이다. 비탈릭 부테린이 개발하였다.비탈릭 부테린은 가상화폐인 비트코인에 사용된 핵심 기술인 블록체인(blockchain)에 화폐 거래 기록뿐 아니라 계약서 등의 추가 정보를 기록할 수 있다는 점에 착안하여, 전 세계 수많은 사용자들이 보유하고 있는 컴퓨팅 자원을 활용해 분산 네트워크를 구성하고, 이 플랫폼을 이용하여 SNS, 이메일, 전자투표 등 다양한 정보를 기록하는 시스템을 창안했다. 이더리움은 C++, 자바, 파이썬, GO 등 주요 프로그래밍 언어를 지원한다.이더리움을 사물 인터넷(IoT)에 적용하면 기계 간 금융 거래도 가능해진다. 예를 들어 고장난 청소로봇이 정비로봇에 돈을 내고 정비를 받고, 청소로봇은 돈을 벌기 위해 정비로봇의 집을 청소하는 것도 가능해진다.', + zh: 'Ethereum(以太坊)是一个平台和一种编程语言,使开发人员能够建立和发布下一代分布式应用。Ethereum 是使用甲醚作为燃料,以激励其网络的第一个图灵完备cryptocurrency。Ethereum(以太坊) 是由Vitalik Buterin的创建。该项目于2014年8月获得了美国1800万$比特币的价值及其crowdsale期间。在2016年,Ethereum(以太坊)的价格上涨超过50倍。', + ja: 'イーサリアム (Ethereum, ETH)・プロジェクトにより開発が進められている、分散型アプリケーション(DApps)やスマート・コントラクトを構築するためのプラットフォームの名称、及び関連するオープンソース・ソフトウェア・プロジェクトの総称である。イーサリアムでは、イーサリアム・ネットワークと呼ばれるP2Pのネットワーク上でスマート・コントラクトの履行履歴をブロックチェーンに記録していく。またイーサリアムは、スマート・コントラクトを記述するチューリング完全なプログラミング言語を持ち、ネットワーク参加者はこのネットワーク上のブロックチェーンに任意のDAppsやスマート・コントラクトを記述しそれを実行することが可能になる。ネットワーク参加者が「Ether」と呼ばれるイーサリアム内部通貨の報酬を目当てに、採掘と呼ばれるブロックチェーンへのスマート・コントラクトの履行結果の記録を行うことで、その正統性を保証していく。このような仕組みにより特定の中央管理組織に依拠せず、P2P全体を実行環境としてプログラムの実行とその結果を共有することが可能になった。', + }, + }, + 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': { + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/8453/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.png', + name: 'USD Coin', + symbol: 'USDC', + type: 'erc20', + }, + 'eip155:8453/slip44:60': { + type: 'native', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/slip44/60.png', + occurrences: 100, + aggregators: [], + erc20Permit: false, + honeypotStatus: {}, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + aggregators: [], + decimals: 9, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44/501.png', + name: 'SOL', + occurrences: 100, + symbol: 'SOL', + type: 'native', + }, + 'stellar:pubnet/slip44:148': { + aggregators: [], + decimals: 7, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/stellar/pubnet/slip44/148.png', + name: 'XLM', + occurrences: 100, + symbol: 'XLM', + type: 'native', + }, + 'tron:728126428/slip44:195': { + aggregators: [], + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/tron/728126428/slip44/195.png', + name: 'TRON', + occurrences: 100, + symbol: 'TRX', + type: 'native', + }, + 'eip155:4663/erc20:0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168': { + type: 'erc20', + name: 'Global Dollar', + symbol: 'USDG', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/4663/erc20/0x5fc5360d0400a0fd4f2af552add042d716f1d168.png', + occurrences: 3, + aggregators: ['metamask', 'oneInch', 'liFi'], + storage: { + approval: 3, + balance: 1, + }, + }, + 'eip155:1/erc20:0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c': { + type: 'erc20', + name: 'Aave v3 USDC', + symbol: 'AUSDC', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c.png', + occurrences: 6, + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'rubic', + 'rango', + 'sonarwatch', + ], + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + storage: { + balance: 52, + approval: 53, + }, + isContractVerified: true, + description: { + en: 'USD Coin in AAVE V3 Ethereum Market', + }, + }, + 'eip155:1/erc20:0x3b484b82567a09e2588A13D54D032153f0c0aEe0': { + type: 'erc20', + name: 'OpenDAO', + symbol: 'SOS', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x3b484b82567a09e2588a13d54d032153f0c0aee0.png', + occurrences: 8, + aggregators: [ + 'coinMarketCap', + 'oneInch', + 'trustWallet', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + description: { + en: 'OpenDAO ($SOS) is a token for the NFT ecosystem. An airdrop is conducted for all users who have traded on OpenSea. Treasury holdings will be used to protect traders on OpenSea, support NFT artists/communities, and developer grant.', + }, + }, + 'eip155:1/erc20:0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84': { + type: 'erc20', + name: 'Liquid staked Ether 2.0', + symbol: 'STETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xae7ab96520de3a18e5e111b5eaab095312d7fe84.png', + occurrences: 7, + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + ], + erc20Permit: true, + fees: { + avgFee: 12.654644390656694, + maxFee: 12.654644390656703, + minFee: 12.654644390656703, + }, + honeypotStatus: { + honeypotIs: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + description: { + en: 'Lido Staked Ether (stETH) is a token that represents your staked ether in Lido, combining the value of initial deposit and staking rewards. stETH tokens are minted upon deposit and burned when redeemed. stETH token balances are pegged 1:1 to the ethers that are staked by Lido and the token’s balances are updated daily to reflect earnings and rewards. stETH tokens can be used as one would use ether, allowing you to earn ETH 2.0 staking rewards whilst benefiting from e.g. yields across decentralised finance products.', + }, + }, + 'eip155:1/erc20:0xc5102fE9359FD9a28f877a67E36B0F050d81a3CC': { + type: 'erc20', + name: 'Hop', + symbol: 'HOP', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc5102fe9359fd9a28f877a67e36b0f050d81a3cc.png', + occurrences: 7, + aggregators: [ + 'coinMarketCap', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + description: { + en: 'Hop is a protocol for sending tokens across rollups and their shared layer-1 network in a quick and trustless manner.', + }, + }, + 'eip155:1/erc20:0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72': { + type: 'erc20', + name: 'Ethereum Name Service', + symbol: 'ENS', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc18360217d8f7ab5e7c516566761ea12ce7f9d72.png', + occurrences: 10, + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'trustWallet', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'bancor', + ], + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + description: { + en: "The Ethereum Name Service (ENS) is a distributed, open, and extensible naming system based on the Ethereum blockchain.ENS’s job is to map human-readable names like ‘alice.eth’ to machine-readable identifiers such as Ethereum addresses, other cryptocurrency addresses, content hashes, and metadata. ENS also supports ‘reverse resolution’, making it possible to associate metadata such as canonical names or interface descriptions with Ethereum addresses.ENS has similar goals to DNS, the Internet’s Domain Name Service, but has significantly different architecture due to the capabilities and constraints provided by the Ethereum blockchain. Like DNS, ENS operates on a system of dot-separated hierarchical names called domains, with the owner of a domain having full control over subdomains.Top-level domains, like ‘.eth’ and ‘.test’, are owned by smart contracts called registrars, which specify rules governing the allocation of their subdomains. Anyone may, by following the rules imposed by these registrar contracts, obtain ownership of a domain for their own use. ENS also supports importing in DNS names already owned by the user for use on ENS.Because of the hierarchal nature of ENS, anyone who owns a domain at any level may configure subdomains - for themselves or others - as desired. For instance, if Alice owns 'alice.eth', she can create 'pay.alice.eth' and configure it as she wishes.ENS is deployed on the Ethereum main network and on several test networks. If you use a library such as the ensjs Javascript library, or an end-user application, it will automatically detect the network you are interacting with and use the ENS deployment on that network.", + }, + }, + 'eip155:8453/erc20:0xE3086852A4B125803C815a158249ae468A3254Ca': { + type: 'erc20', + name: 'mfercoin', + symbol: 'MFER', + decimals: 18, + occurrences: 6, + aggregators: ['coinGecko', 'liFi', 'rubic', 'squid', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0xcdE172dc5ffC46D228838446c57C1227e0B82049': { + type: 'erc20', + name: 'Boomer', + symbol: 'BOOMER', + decimals: 18, + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0xba71Cb8Ef2d59dE7399745793657838829E0B147': { + type: 'erc20', + name: 'Siamese', + symbol: 'SIAM', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0xba71cb8ef2d59de7399745793657838829e0b147.png', + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + description: { + en: 'One of the first community owned Meme tokens on the base chain! ', + }, + }, + 'eip155:8453/erc20:0xb56d0839998Fd79EFCD15c27cF966250AA58D6D3': { + type: 'erc20', + name: 'Based USA', + symbol: 'USA', + decimals: 18, + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 2, + balance: 1, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0xA0aeBd4Ae5F256B72B7D43f67eD934237Adb1AeE': { + type: 'erc20', + name: 'BONSAI COIN', + symbol: 'BONSAICOIN', + decimals: 18, + occurrences: 3, + aggregators: ['coinGecko', 'rubic', 'rango'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 6, + balance: 5, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x6223901eA64608c75Da8497d5eff15D19A1D8fd5': { + type: 'erc20', + name: 'Corgi', + symbol: 'CORGI', + decimals: 18, + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x2C001233eD5E731B98B15B30267F78C7560b71f2': { + type: 'erc20', + name: 'BUBU', + symbol: 'BUBU', + decimals: 18, + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x4d58608EFf50b691A3B76189aF2a7A123dF1e9ba': { + type: 'erc20', + name: 'Boysclubbase', + symbol: '$BOYS', + decimals: 9, + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 2, + balance: 1, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x9aAaE745cf2830FB8DDc6248B17436dC3a5E701C': { + type: 'erc20', + name: 'Gochujangcoin', + symbol: 'GOCHU', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x9aaae745cf2830fb8ddc6248b17436dc3a5e701c.png', + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + description: { + en: "Gochujangcoin draws its inspiration from the renowned Korean condiment, Gochujang, and plans to expand its reach through related games, NFTs, and K-food recipes. The innovative 'Tap to Earn' games offer users new culinary experiences and encourage active participation, positioning Gochujangcoin as more substantive than typical investment tokens. It fosters an active community through K-food recipes, rewarding engagement with tokens and offering unique K-food-themed NFTs, blending culinary heritage with blockchain technology.", + }, + }, + 'eip155:8453/erc20:0x1b6A569DD61EdCe3C383f6D565e2f79Ec3a12980': { + type: 'erc20', + name: 'Young Peezy AKA Pepe', + symbol: 'PEEZY', + decimals: 18, + occurrences: 4, + aggregators: ['metamask', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x174e33Ef2efFa0a4893d97DDa5db4044cC7993a3': { + type: 'erc20', + name: 'Keren', + symbol: 'KEREN', + decimals: 18, + occurrences: 3, + aggregators: ['rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 2, + balance: 1, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x623cD3a3EdF080057892aaF8D773Bbb7A5C9b6e9': { + type: 'erc20', + name: 'Sekuya Multiverse', + symbol: 'SKYA', + decimals: 18, + occurrences: 5, + aggregators: ['coinGecko', 'liFi', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 2, + balance: 1, + }, + isContractVerified: true, + description: { + en: 'Sekuya is a video game company headquartered in Singapore. Born from a community, Sekuya aims to revolutionize the gaming landscape with a community-driven approach in all new anime epic fantasy universe. Sekuya’s flagship project, Sekuya Multiverse, an award-winning start-up project, combines 2 of the world’s most popular gaming genres: MOBA + RPG, promising a new gaming experience for global players of both genres. Problem: The current fast-growing MOBA gaming genres have not received a significant gameplay update since around 2003. Additionally, despite the total gaming revenue reaching $180 billion in 2023, game item ownership remains centralized. Approach: An entirely new genre of Epic Fantasy MOBA MMORPG powered by a unique Web3 ownership (heroes, items, skills, pets) and AI co-creation tools (user generated skin & personalized superpower). Positioning: We are among the pioneers in introducing a completely unique gameplay experience, along with Web3 ownership and AI co-creation tools that have the potential to appeal to millions of gamers and creators. Sekuya Multiverse, an award-winning start up project with GAMEFI AI RWA narrative, combines 2 of the world’s most favorite gaming genres: MOBA MMORPG, promising a new experience for 250 million global players Supported by over 100 communities in Southeast Asia, Sekuya Multiverse offers an immersive MMORPG experience set in the Novae Terrae, a 10-world universe. Players, known as "Jumpers," can utilize an AI character creator to customize their own character, interact with AI NPCs, embark on engaging storylines, and participate in battles to collect 400+ sekumon souls and win the grand rewards. Anticipate an exhilarating 5v5 MOBA featuring unique superpowers bestowed by Sekuya heroes and special abilities tailored to each player\'s personality.', + }, + }, + 'eip155:8453/erc20:0xE4fCf2D991505089bBb36275570757c1f9800cB0': { + type: 'erc20', + name: 'Purrcoin', + symbol: 'PURR', + decimals: 18, + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + storage: {}, + isContractVerified: true, + }, + 'eip155:8453/erc20:0xF8a99F2bF2ce5bb6cE4aafcf070D8723bc904Aa2': { + type: 'erc20', + name: 'Chinese Brett', + symbol: 'CHRETT', + decimals: 18, + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 9, + balance: 8, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x653A143B8d15C565C6623D1F168cFbeC1056D872': { + type: 'erc20', + name: 'kurbi', + symbol: 'KURBI', + decimals: 9, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x653a143b8d15c565c6623d1f168cfbec1056d872.png', + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 2, + balance: 1, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x9DE16c805A3227b9b92e39a446F9d56cf59fe640': { + type: 'erc20', + name: 'Bento', + symbol: 'BENTO', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x9de16c805a3227b9b92e39a446f9d56cf59fe640.png', + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + description: { + en: 'A Dog Meme Coin On Base', + }, + }, + 'eip155:8453/erc20:0x07d15798a67253D76cea61F0eA6F57AeDC59DffB': { + type: 'erc20', + name: 'Based Coin', + symbol: 'BASED', + decimals: 18, + occurrences: 3, + aggregators: ['coinGecko', 'rubic', 'rango'], + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x40E3eDDF6d253BB734381A309437428f121c594b': { + type: 'erc20', + name: 'Larva Lads', + symbol: 'LAD', + decimals: 18, + occurrences: 3, + aggregators: ['rubic', 'rango', 'sonarwatch'], + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0xb8D98a102b0079B69FFbc760C8d857A31653e56e': { + type: 'erc20', + name: 'toby', + symbol: 'TOBY', + decimals: 18, + occurrences: 7, + aggregators: [ + 'coinGecko', + 'oneInch', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + description: { + en: 'cute frog community project airdropped to entire base community', + }, + }, + 'eip155:137/erc20:0xEDcFb6984a3c70501BAA8b7f5421Ae795ecC1496': { + type: 'erc20', + name: 'ABCMETA Token', + symbol: 'META', + decimals: 8, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/0xedcfb6984a3c70501baa8b7f5421ae795ecc1496.png', + occurrences: 3, + aggregators: ['sonarwatch', 'rubic', 'rango'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:10/erc20:0x4200000000000000000000000000000000000042': { + type: 'erc20', + name: 'Optimism', + symbol: 'OP', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0x4200000000000000000000000000000000000042.png', + occurrences: 11, + aggregators: [ + 'uniswap', + 'oneInch', + 'liFi', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: false, + description: { + en: "OP is the token for the Optimism Collective that governs the Optimism L2 blockchain. The Optimism Collective is a large-scale experiment in digital democratic governance, built to drive rapid and sustainable growth of a decentralized ecosystem, and stewarded by the newly formed Optimism Foundation.OP governs upgrades to the protocol and network parameters, and creates an ongoing system of incentives for projects and users in the Optimism ecosystem. 5.4% of the total token supply will be distributed to projects on Optimism over the next six months via governance. If you're building something in the Ethereum ecosystem, you can consider applying for the grant.", + }, + }, + 'eip155:59144/erc20:0x374D7860c4f2f604De0191298dD393703Cce84f3': { + type: 'erc20', + name: 'Aave v3 USDC', + symbol: 'AUSDC', + decimals: 6, + occurrences: 5, + aggregators: ['metamask', 'oneInch', 'liFi', 'rubic', 'rango'], + storage: { + approval: 53, + }, + isContractVerified: true, + }, + 'eip155:10/erc20:0xEcF46257ed31c329F204Eb43E254C609dee143B3': { + type: 'erc20', + name: 'RigoBlock', + symbol: 'GRG', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0xecf46257ed31c329f204eb43e254c609dee143b3.png', + occurrences: 4, + aggregators: ['uniswap', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + description: { + en: '"RigoBlock exists to reinvent the asset management industry, making it possible for anyone, anywhere, to set up and manage decentralized token pools which combine the powers of transparency, control, flexibility and governance. By virtue of its modular architecture, developers can build their own distributed asset management platforms atop of the RigoBlock protocol and leverage the unique technology made available by RigoBlock protocol and the Rigo Token (‘GRG’) incentives mechanism. Through the creation of a revolutionary Proof-of-Performance incentive algorithm, RigoBlock removes the need for antiquated management fees to facilitate a new generation of asset management - one built around trust, transparency and simplicity."', + }, + }, + 'eip155:10/erc20:0x4B03afC91295ed778320c2824bAd5eb5A1d852DD': { + type: 'erc20', + name: 'NBL', + symbol: 'NBL', + decimals: 18, + occurrences: 3, + aggregators: ['sonarwatch', 'rubic', 'rango'], + erc20Permit: true, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0xfF62dDfa80E513114C3a0bf4d6fFff1c1D17aADf': { + type: 'erc20', + name: 'Boe', + symbol: 'BOE', + decimals: 18, + occurrences: 4, + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 8, + balance: 7, + }, + isContractVerified: true, + }, + 'eip155:42161/erc20:0x912CE59144191C1204E64559FE8253a0e49E6548': { + type: 'erc20', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/42161/erc20/0x912ce59144191c1204e64559fe8253a0e49e6548.png', + occurrences: 9, + aggregators: [ + 'traderJoe', + 'oneInch', + 'liFi', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + erc20Permit: true, + honeypotStatus: {}, + storage: { + balance: 51, + approval: 52, + }, + isContractVerified: true, + description: { + en: 'Arbitrum is one of the leading Ethereum scaling solutions bringing cheap transactions to tens of thousands of users in an environment that feels very similar to Ethereum. It is an optimistic rollup and the leading L2 in terms of TVL. Some of the largest dApps live on Arbitrum include GMX, Radiant, Uniswap V3, and Gains Network.', + }, + }, + 'eip155:42161/erc20:0x539bdE0d7Dbd336b79148AA742883198BBF60342': { + type: 'erc20', + name: 'MAGIC', + symbol: 'MAGIC', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/42161/erc20/0x539bde0d7dbd336b79148aa742883198bbf60342.png', + occurrences: 8, + aggregators: [ + 'traderJoe', + 'oneInch', + 'liFi', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + erc20Permit: true, + honeypotStatus: {}, + storage: { + approval: 52, + balance: 51, + }, + isContractVerified: true, + }, + 'eip155:1/erc20:0xE52d53c8C9aa7255F8c2FA9f7093FEa7192D2933': { + type: 'erc20', + name: 'yield-farming.io', + symbol: 'YIELDX', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xe52d53c8c9aa7255f8c2fa9f7093fea7192d2933.png', + occurrences: 2, + aggregators: ['coinMarketCap', 'rubic'], + erc20Permit: false, + fees: { + avgFee: 2.499999999999999, + maxFee: 2.5, + minFee: 2.5, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + }, + 'eip155:56/erc20:0x0400Ff00fFd395Ef93E701aE27087A7eeeb84f32': { + type: 'erc20', + name: 'ZooBit.Org', + symbol: 'ZB', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x0400ff00ffd395ef93e701ae27087a7eeeb84f32.png', + occurrences: 2, + aggregators: ['rubic', 'rango'], + storage: { + approval: 2, + balance: 1, + }, + isContractVerified: true, + }, + 'eip155:1/erc20:0xbaA70614C7AAfB568a93E62a98D55696bcc85DFE': { + type: 'erc20', + name: 'UniCap.finance', + symbol: 'UCAP', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xbaa70614c7aafb568a93e62a98d55696bcc85dfe.png', + occurrences: 2, + aggregators: ['coinMarketCap', 'rubic'], + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: true, + goPlus: false, + }, + storage: { + balance: 4, + approval: 5, + }, + isContractVerified: true, + }, + 'eip155:1/erc20:0x9D24364b97270961b2948734aFe8d58832Efd43a': { + type: 'erc20', + name: 'yefam.finance', + symbol: 'FAM', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x9d24364b97270961b2948734afe8d58832efd43a.png', + occurrences: 1, + aggregators: ['rubic'], + erc20Permit: false, + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: true, + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0xfB18511F1590a494360069F3640c27d55c2B5290': { + type: 'erc20', + name: 'Wild Goat Coin', + symbol: 'WGC', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0xfb18511f1590a494360069f3640c27d55c2b5290.png', + occurrences: 2, + aggregators: ['rubic', 'rango'], + erc20Permit: true, + storage: { + approval: 6, + balance: 5, + }, + isContractVerified: true, + }, + 'eip155:10/erc20:0xad984fBd3Fb10d0B47D561bE7295685aF726fDb3': { + type: 'erc20', + name: 'LARRY TALBOT', + symbol: 'LARRY', + decimals: 18, + occurrences: 1, + aggregators: ['rubic'], + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + honeypotStatus: { + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0xAfB5d4d474693e68Df500c9c682E6A2841f9661A': { + type: 'erc20', + name: 'Bloomer', + symbol: 'BLOOM', + decimals: 18, + occurrences: 1, + aggregators: ['rango'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x340C070260520ae477b88CAA085a33531897145b': { + type: 'erc20', + name: 'Shigure UI', + symbol: '9MM', + decimals: 18, + occurrences: 1, + aggregators: ['rubic'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + balance: 7, + approval: 8, + }, + isContractVerified: true, + }, + 'eip155:1/erc20:0x82866b4A71BA9d930Fe338C386B6A45a7133eb36': { + type: 'erc20', + name: 'QCORE.FINANCE', + symbol: 'QCORE', + decimals: 9, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x82866b4a71ba9d930fe338c386b6a45a7133eb36.png', + occurrences: 2, + aggregators: ['coinMarketCap', 'rubic'], + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: true, + goPlus: false, + }, + storage: { + balance: 3, + approval: 4, + }, + isContractVerified: true, + }, + 'eip155:1/erc20:0x43e6228b5bF22Eab754486082cA91FdD8585521A': { + type: 'erc20', + name: 'DIXT.FINANCE', + symbol: 'DIXT', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x43e6228b5bf22eab754486082ca91fdd8585521a.png', + occurrences: 2, + aggregators: ['coinMarketCap', 'rubic'], + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + }, + 'eip155:1/erc20:0x5380442d3C4EC4f5777f551f5EDD2FA0F691A27C': { + type: 'erc20', + name: 'UkraineDAO Flag NFT', + symbol: 'LOVE', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x5380442d3c4ec4f5777f551f5edd2fa0f691a27c.png', + occurrences: 2, + aggregators: ['coinMarketCap', 'rubic'], + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + storage: { + balance: 51, + approval: 52, + }, + isContractVerified: true, + description: { + en: '$LOVE token was donated to everyone who donated to the UkraineDAO Party Bid and for everyone who donated directly to the ukrainedao.eth prior to the snapshot on Mar 3. The $LOVE token is a symbol, not a utility, commemorating the donor’s contribution. Seeing these tokens or other POAPs in one’s wallet reminds people of the bigger picture behind Web3 building and decentralized organizations.', + }, + }, + 'eip155:1/erc20:0x6051C1354Ccc51b4d561e43b02735DEaE64768B8': { + type: 'erc20', + name: 'yRise.Finance', + symbol: 'YRISE', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x6051c1354ccc51b4d561e43b02735deae64768b8.png', + occurrences: 2, + aggregators: ['coinMarketCap', 'rubic'], + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + storage: { + balance: 4, + approval: 5, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x160452f95612699D1a561A70EEEeeDe67c6812af': { + type: 'erc20', + name: 'Base Lord', + symbol: 'BORD', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x160452f95612699d1a561a70eeeeede67c6812af.png', + occurrences: 1, + aggregators: ['rango'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + description: { + en: 'BORD is an original memecoin project aimed at bringing more users to the Base chain with its fun, clever, and nostalgic memes. BORD has a strong community focus that strives to show old and new crypto enthusiasts the power of the based side, with the help of its rich lore and storytelling.', + }, + }, + 'eip155:10/erc20:0x67631FF69130ea1a6c4feaA4A0Abf0a1E0148be7': { + type: 'erc20', + name: 'Wild Goat Coin', + symbol: 'WGC', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0x67631ff69130ea1a6c4feaa4a0abf0a1e0148be7.png', + occurrences: 2, + aggregators: ['rubic', 'rango'], + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + storage: { + balance: 5, + approval: 6, + }, + isContractVerified: true, + description: { + en: 'Memecoin / Digital Collectible', + }, + }, + 'eip155:8453/erc20:0x4F6e6B8efC7CfB23DBD53C1B09F7389eF8191693': { + type: 'erc20', + name: 'AI Love Meme', + symbol: 'AILM', + decimals: 18, + occurrences: 1, + aggregators: ['rubic'], + honeypotStatus: { + goPlus: false, + }, + isContractVerified: true, + }, + 'eip155:137/erc20:0x3C0Bd2118a5E61C41d2aDeEBCb8B7567FDE1cBaF': { + type: 'erc20', + name: 'Cookie', + symbol: 'CKIE', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/0x3c0bd2118a5e61c41d2adeebcb8b7567fde1cbaf.png', + occurrences: 1, + aggregators: ['rubic'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + balance: 0, + approval: 1, + }, + isContractVerified: true, + }, + 'eip155:137/erc20:0x9E2d266D6c90F6C0D80a88159b15958f7135B8Af': { + type: 'erc20', + name: 'StakeShare', + symbol: 'SSX', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/0x9e2d266d6c90f6c0d80a88159b15958f7135b8af.png', + occurrences: 1, + aggregators: ['rubic'], + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + goPlus: false, + }, + storage: { + balance: 3, + approval: 1, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x9A27C6759A6de0F26Ac41264f0856617DeC6bC3F': { + type: 'erc20', + name: 'Monkey Peepo', + symbol: 'BANANAS', + decimals: 18, + occurrences: 2, + aggregators: ['rubic', 'rango'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x41e357ea17eEd8e3Ee32451F8E5CBa824AF58Dbf': { + type: 'erc20', + name: 'Coinbase Wrapped XRP', + symbol: 'CBXRP', + decimals: 18, + occurrences: 1, + aggregators: ['rubic'], + }, + 'eip155:8453/erc20:0xC5a861787f3e173F2b004d5cfA6a717f5DC5484D': { + type: 'erc20', + name: 'Snow Leopard', + symbol: 'SNL', + decimals: 18, + occurrences: 1, + aggregators: ['rubic'], + }, + 'eip155:8453/erc20:0x80E3Ee7bAB68fEAea6e01c44df9daa5de53e4818': { + type: 'erc20', + name: 'DOGECOIN', + symbol: 'DOGE', + decimals: 9, + occurrences: 1, + aggregators: ['rubic'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + balance: 1, + approval: 2, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x478e03D45716dDa94F6DbC15A633B0D90c237E2F': { + type: 'erc20', + name: 'Shaka', + symbol: '$SHAKA', + decimals: 18, + occurrences: 2, + aggregators: ['rubic', 'rango'], + erc20Permit: false, + honeypotStatus: { + goPlus: false, + }, + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, + 'eip155:8453/erc20:0x491B67a94Ec0a59b81b784F4719d0387C4510c36': { + type: 'erc20', + name: 'Purple Frog', + symbol: 'PF', + decimals: 18, + occurrences: 2, + aggregators: ['rubic', 'rango'], + storage: { + approval: 1, + balance: 0, + }, + isContractVerified: true, + }, +}; + +/** Native asset id per chain, verbatim from the state log. */ +export const SCAM_WALLET_NATIVE_ASSET_IDENTIFIERS = { + 'eip155:1': 'eip155:1/slip44:60', + 'eip155:10': 'eip155:10/slip44:60', + 'eip155:10143': 'eip155:10143/slip44:1', + 'eip155:11155111': 'eip155:11155111/slip44:1', + 'eip155:137': 'eip155:137/slip44:966', + 'eip155:143': 'eip155:143/slip44:268435779', + 'eip155:42161': 'eip155:42161/slip44:60', + 'eip155:4663': 'eip155:4663/slip44:60', + 'eip155:534352': 'eip155:534352/slip44:60', + 'eip155:56': 'eip155:56/slip44:714', + 'eip155:59141': 'eip155:59141/slip44:1', + 'eip155:59144': 'eip155:59144/slip44:60', + 'eip155:6343': 'eip155:6343/slip44:60', + 'eip155:8453': 'eip155:8453/slip44:60', +}; + +/** + * The asset ids the sweep is expected to keep: every asset except the + * sub-floor ERC-20 spam. Computed once the captured occurrence counts are + * known; exported here so the integration test asserts the exact survivor + * set. + */ +/** + * The sub-floor ERC-20 airdrop spam the sweep is expected to drop, computed + * from the captured occurrence counts in `api-responses/`. Each sits below its + * chain's floor (3 everywhere except the thin Monad/Linea-style chains) and is + * neither custom nor mUSD. + */ +export const SCAM_WALLET_SPAM_ASSET_IDS = [ + 'eip155:137/erc20:0xEDcFb6984a3c70501BAA8b7f5421Ae795ecC1496', + 'eip155:10/erc20:0x4B03afC91295ed778320c2824bAd5eb5A1d852DD', + 'eip155:1/erc20:0xE52d53c8C9aa7255F8c2FA9f7093FEa7192D2933', + 'eip155:56/erc20:0x0400Ff00fFd395Ef93E701aE27087A7eeeb84f32', + 'eip155:1/erc20:0xbaA70614C7AAfB568a93E62a98D55696bcc85DFE', + 'eip155:1/erc20:0x9D24364b97270961b2948734aFe8d58832Efd43a', + 'eip155:8453/erc20:0xfB18511F1590a494360069F3640c27d55c2B5290', + 'eip155:10/erc20:0xad984fBd3Fb10d0B47D561bE7295685aF726fDb3', + 'eip155:8453/erc20:0xAfB5d4d474693e68Df500c9c682E6A2841f9661A', + 'eip155:8453/erc20:0x340C070260520ae477b88CAA085a33531897145b', + 'eip155:1/erc20:0x82866b4A71BA9d930Fe338C386B6A45a7133eb36', + 'eip155:1/erc20:0x43e6228b5bF22Eab754486082cA91FdD8585521A', + 'eip155:1/erc20:0x5380442d3C4EC4f5777f551f5EDD2FA0F691A27C', + 'eip155:1/erc20:0x6051C1354Ccc51b4d561e43b02735DEaE64768B8', + 'eip155:8453/erc20:0x160452f95612699D1a561A70EEEeeDe67c6812af', + 'eip155:10/erc20:0x67631FF69130ea1a6c4feaA4A0Abf0a1E0148be7', + 'eip155:8453/erc20:0x4F6e6B8efC7CfB23DBD53C1B09F7389eF8191693', + 'eip155:137/erc20:0x3C0Bd2118a5E61C41d2aDeEBCb8B7567FDE1cBaF', + 'eip155:137/erc20:0x9E2d266D6c90F6C0D80a88159b15958f7135B8Af', + 'eip155:8453/erc20:0x9A27C6759A6de0F26Ac41264f0856617DeC6bC3F', + 'eip155:8453/erc20:0x41e357ea17eEd8e3Ee32451F8E5CBa824AF58Dbf', + 'eip155:8453/erc20:0xC5a861787f3e173F2b004d5cfA6a717f5DC5484D', + 'eip155:8453/erc20:0x80E3Ee7bAB68fEAea6e01c44df9daa5de53e4818', + 'eip155:8453/erc20:0x478e03D45716dDa94F6DbC15A633B0D90c237E2F', + 'eip155:8453/erc20:0x491B67a94Ec0a59b81b784F4719d0387C4510c36', +] as Caip19AssetId[]; + +/** + * Everything that must survive the sweep: the genuine holdings that clear the + * floor, the hand-imported custom asset, the mUSD entries, plus every native + * and non-EVM asset (which the occurrence filter never touches). Derived from + * the captured responses; asserted as the exact post-sweep `assetsInfo` keys. + */ +export const SCAM_WALLET_SURVIVING_ASSET_IDS = ( + Object.keys(SCAM_WALLET_ASSETS_INFO) as Caip19AssetId[] +).filter( + (assetId) => + !SCAM_WALLET_SPAM_ASSET_IDS.some( + (spamId) => spamId.toLowerCase() === assetId.toLowerCase(), + ), +); + +/** + * Build the example scam wallet's controller state. All assets are tracked + * under a single synthetic account's balances (plus the real custom-asset + * account) so the sweep's per-account balance cleanup is exercised. + * + * @param overrides - State slices to replace wholesale. + * @returns Full internal controller state. + */ +export function buildScamWalletState( + overrides: Partial = {}, +): AssetsControllerStateInternal { + const assetsInfo = SCAM_WALLET_ASSETS_INFO as Record< + Caip19AssetId, + AssetMetadata + >; + return { + assetsInfo, + assetsBalance: { + [SCAM_WALLET_ACCOUNT_ID]: Object.fromEntries( + Object.keys(assetsInfo).map((assetId) => [ + assetId, + { amount: '1000000000000000000' }, + ]), + ), + }, + assetsPrice: {}, + customAssets: { ...SCAM_WALLET_CUSTOM_ASSETS }, + assetPreferences: {}, + selectedCurrency: 'usd', + ...overrides, + }; +} diff --git a/packages/assets-controller/src/__fixtures__/spamWalletState.ts b/packages/assets-controller/src/__fixtures__/spamWalletState.ts new file mode 100644 index 00000000000..73ac89fa4b3 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/spamWalletState.ts @@ -0,0 +1,357 @@ +import { KnownCaipNamespace, getChecksumAddress } from '@metamask/utils'; + +import { getDefaultTrackedAssetsForChain } from '../defaults.js'; +import type { + AssetMetadata, + AssetsControllerStateInternal, + Caip19AssetId, + ChainId, +} from '../types.js'; + +/** + * A wallet that came through the boundary faults the spam sweep cleans up + * after — the Token API loosening its filters, the websocket pushing + * unfiltered balances. Airdrop spam sits in `assetsInfo` and in two accounts' + * balances, next to genuine holdings, a hand-imported token, and the + * default-tracked mUSD entry every wallet ships with. + * + * The tokens are real, and their metadata is what + * `tokens.api.cx.metamask.io/v3/assets` returns for them today. Every EVM + * asset ID is EIP-55 checksummed, the way the data sources write them into + * state. + */ + +export const ACCOUNT_ONE_ID = '5c6ab8b4-4f2c-4f21-9d0e-4ef1c1e6f0a2'; +export const ACCOUNT_ONE_ADDRESS = '0x2f318C334780961FB129D2a6c30D0763d9a5C970'; +export const ACCOUNT_TWO_ID = 'b0f1b8ba-3f18-4a1e-8c31-2b3ad9f6e771'; +export const ACCOUNT_TWO_ADDRESS = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; + +/** mUSD on mainnet, pre-seeded into every wallet's `assetsInfo`. */ +export const [MAINNET_MUSD] = getDefaultTrackedAssetsForChain( + 'eip155:1' as ChainId, +); +export const MAINNET_NATIVE = 'eip155:1/slip44:60' as Caip19AssetId; +export const MAINNET_USDT = + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as Caip19AssetId; +export const OPTIMISM_USDC = + 'eip155:10/erc20:0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85' as Caip19AssetId; +export const OPTIMISM_VELO = + 'eip155:10/erc20:0x9560e827aF36c94D2Ac33a39bCE1Fe78631088Db' as Caip19AssetId; +export const BASE_USDC = + 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as Caip19AssetId; +/** Sits exactly on the floor Base falls back to. */ +export const BASE_FARTCOIN = + 'eip155:8453/erc20:0x2f6c17fa9f9bC3600346ab4e48C0701e1d5962AE' as Caip19AssetId; +/** Thinly listed, and only survives because Sei's suggested floor is 1. */ +export const SEI_USDCN = + 'eip155:1329/erc20:0x3894085Ef7Ff0f0aeDf52E2A2704928d1Ec074F1' as Caip19AssetId; +/** Imported by hand, so tracked in `customAssets`. */ +export const ARBITRUM_GMX = + 'eip155:42161/erc20:0xfc5A1A6EB076a2C7aD06eD22C90d7E710E35ad0a' as Caip19AssetId; +export const SOLANA_USDC = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; +/** Flare is not covered by the Accounts API, so its tokens were never detected. */ +export const FLARE_SFLR = + 'eip155:14/erc20:0x12e605bc104e93B45e1aD99F9e555f659051c2BB' as Caip19AssetId; +/** + * A real token the API describes but reports no occurrence count for, which is + * how it answers for every Monad token today. + */ +export const MONAD_WMON = + 'eip155:143/erc20:0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A' as Caip19AssetId; + +export const MAINNET_SPAM = + 'eip155:1/erc20:0xB5f0e1b64a4a1a2A6cbf0E8f9d0c4e7A1b2C3D4E' as Caip19AssetId; +export const OPTIMISM_SPAM = + 'eip155:10/erc20:0x6A1f0b9c3e5d7a2B4C8E0f1a3D5B7C9E2F4A6b8d' as Caip19AssetId; +export const BASE_SPAM = + 'eip155:8453/erc20:0xD4e2a7B1C3f5089E6a4b8d0c2F7E9A1b3C5d7e9f' as Caip19AssetId; + +/** The airdrop spam the sweep is expected to drop. */ +export const SPAM_ASSET_IDS = [MAINNET_SPAM, OPTIMISM_SPAM, BASE_SPAM]; + +/** Everything else the fixture wallet holds, all of which must survive. */ +export const SURVIVING_ASSET_IDS = [ + MAINNET_MUSD, + MAINNET_NATIVE, + MAINNET_USDT, + OPTIMISM_USDC, + OPTIMISM_VELO, + BASE_USDC, + BASE_FARTCOIN, + SEI_USDCN, + ARBITRUM_GMX, + SOLANA_USDC, + FLARE_SFLR, +]; + +/** + * The assets the sweep is allowed to ask the Token API about: ERC-20s on + * Accounts-API chains that are neither default-tracked nor user-imported. + */ +export const SWEEPABLE_ASSET_IDS = [ + MAINNET_USDT, + MAINNET_SPAM, + OPTIMISM_USDC, + OPTIMISM_VELO, + OPTIMISM_SPAM, + BASE_USDC, + BASE_FARTCOIN, + BASE_SPAM, + SEI_USDCN, +]; + +/** Assets the sweep must never consider, whatever the Token API says. */ +export const OUT_OF_SCOPE_ASSET_IDS = [ + MAINNET_MUSD, + MAINNET_NATIVE, + SOLANA_USDC, + FLARE_SFLR, + ARBITRUM_GMX, +]; + +/** + * Metadata for every token the fixtures know about, as the API describes it. + */ +const TOKEN_METADATA: Record = { + [MAINNET_MUSD]: { + type: 'erc20', + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + }, + [MAINNET_NATIVE]: { + type: 'native', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + [MAINNET_USDT]: { + type: 'erc20', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + }, + [MAINNET_SPAM]: { + type: 'erc20', + symbol: '$ USDC-Voucher.com', + name: 'Claim 5,000 USDC at USDC-Voucher.com', + decimals: 18, + }, + [OPTIMISM_USDC]: { + type: 'erc20', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + [OPTIMISM_VELO]: { + type: 'erc20', + symbol: 'VELO', + name: 'Velodrome Finance', + decimals: 18, + }, + [OPTIMISM_SPAM]: { + type: 'erc20', + symbol: '! OP-Rewards.xyz', + name: 'Visit OP-Rewards.xyz to unlock', + decimals: 18, + }, + [BASE_USDC]: { + type: 'erc20', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + [BASE_FARTCOIN]: { + type: 'erc20', + symbol: 'FARTCOIN', + name: 'Based Fartcoin', + decimals: 18, + }, + [BASE_SPAM]: { + type: 'erc20', + symbol: 'BASE-DROP', + name: 'base-drop.net reward', + decimals: 18, + }, + [SEI_USDCN]: { + type: 'erc20', + symbol: 'USDCN', + name: 'Noble USDC', + decimals: 6, + }, + [MONAD_WMON]: { + type: 'erc20', + symbol: 'WMON', + name: 'Wrapped MON', + decimals: 18, + }, + [ARBITRUM_GMX]: { + type: 'erc20', + symbol: 'GMX', + name: 'GMX', + decimals: 18, + }, + [SOLANA_USDC]: { + type: 'spl', + symbol: 'USDC', + name: 'USDC', + decimals: 6, + }, + [FLARE_SFLR]: { + type: 'erc20', + symbol: 'sFLR', + name: 'Staked FLR', + decimals: 18, + }, +}; + +/** + * Build an `assetsInfo` registry holding only the given assets. + * + * @param assetIds - The assets to keep. + * @returns The registry. + */ +export function buildAssetsInfo( + assetIds: Caip19AssetId[], +): Record { + return Object.fromEntries( + assetIds.map((assetId) => [assetId, TOKEN_METADATA[assetId]]), + ); +} + +/** + * Look up the metadata the API holds for an asset, if it knows it at all. + * + * @param assetId - The CAIP-19 asset ID, in any casing. + * @returns The metadata, or undefined for a token no list carries. + */ +export function findTokenMetadata(assetId: string): AssetMetadata | undefined { + const match = Object.keys(TOKEN_METADATA).find( + (knownId) => knownId.toLowerCase() === assetId.toLowerCase(), + ); + return match ? TOKEN_METADATA[match as Caip19AssetId] : undefined; +} + +export const SPAM_WALLET_ASSETS_INFO = buildAssetsInfo([ + ...SURVIVING_ASSET_IDS, + ...SPAM_ASSET_IDS, +]); + +export const SPAM_WALLET_BALANCES = { + [ACCOUNT_ONE_ID]: { + [MAINNET_NATIVE]: { amount: '1204500000000000000' }, + [MAINNET_USDT]: { amount: '2500000000' }, + [MAINNET_SPAM]: { amount: '5000000000000000000000' }, + [OPTIMISM_USDC]: { amount: '148230000' }, + [OPTIMISM_SPAM]: { amount: '1000000000000000000000' }, + [BASE_SPAM]: { amount: '4200000000000000000000' }, + [SEI_USDCN]: { amount: '74500000' }, + }, + [ACCOUNT_TWO_ID]: { + [MAINNET_SPAM]: { amount: '5000000000000000000000' }, + [BASE_FARTCOIN]: { amount: '1200000000000000000000' }, + [ARBITRUM_GMX]: { amount: '3400000000000000000' }, + }, +}; + +/** + * Build the spam wallet's controller state. + * + * @param overrides - State slices to replace wholesale. + * @returns Full internal controller state. + */ +export function buildSpamWalletState( + overrides: Partial = {}, +): AssetsControllerStateInternal { + return { + assetsInfo: { ...SPAM_WALLET_ASSETS_INFO }, + assetsBalance: structuredClone(SPAM_WALLET_BALANCES), + assetsPrice: { + [MAINNET_USDT]: { + assetPriceType: 'fungible', + price: 1.0002, + lastUpdated: 1_756_200_000_000, + usdPrice: 1.0002, + }, + }, + customAssets: { [ACCOUNT_TWO_ID]: [ARBITRUM_GMX] }, + assetPreferences: { [OPTIMISM_VELO]: { hidden: true } }, + selectedCurrency: 'usd', + ...overrides, + }; +} + +/** + * Build the spam wallet as a client that never checksummed its EVM asset IDs + * holds it: `assetsInfo` and every account's balances are keyed in lowercase, + * while `customAssets` keeps the checksummed IDs the import path writes, so + * the two slices disagree on casing. Solana IDs are left as they are, because + * base58 is case-sensitive. + * + * @returns Full internal controller state. + */ +export function buildLowercasedSpamWalletState(): AssetsControllerStateInternal { + const state = buildSpamWalletState(); + + return { + ...state, + assetsInfo: lowercaseKeys(state.assetsInfo), + assetsBalance: Object.fromEntries( + Object.entries(state.assetsBalance).map(([accountId, balances]) => [ + accountId, + lowercaseKeys(balances), + ]), + ), + }; +} + +/** + * Rekey a registry by lowercase EVM asset ID. + * + * @param registry - The registry to rekey. + * @returns The rekeyed registry. + */ +function lowercaseKeys( + registry: Record, +): Record { + return Object.fromEntries( + Object.entries(registry).map(([assetId, value]) => [ + assetId.startsWith(`${KnownCaipNamespace.Eip155}:`) + ? assetId.toLowerCase() + : assetId, + value, + ]), + ); +} + +/** + * Build a wallet holding `count` distinct Optimism tokens and nothing else, + * for exercising the batching a heavily airdropped wallet goes through. + * + * @param count - How many tokens the wallet holds. + * @returns The asset IDs, in the order the sweep will batch them, alongside + * the controller state holding them. + */ +export function buildManyTokensState(count: number): { + assetIds: Caip19AssetId[]; + state: AssetsControllerStateInternal; +} { + const assetIds = Array.from({ length: count }, (_, index) => { + const address = getChecksumAddress( + `0x${(index + 1).toString(16).padStart(40, '0')}`, + ); + return `eip155:10/erc20:${address}` as Caip19AssetId; + }); + + return { + assetIds, + state: buildSpamWalletState({ + assetsInfo: Object.fromEntries( + assetIds.map((assetId) => [assetId, TOKEN_METADATA[OPTIMISM_SPAM]]), + ), + assetsBalance: {}, + customAssets: {}, + }), + }; +} diff --git a/packages/assets-controller/src/__fixtures__/test-utils.ts b/packages/assets-controller/src/__fixtures__/test-utils.ts new file mode 100644 index 00000000000..e57363787e8 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/test-utils.ts @@ -0,0 +1,43 @@ +type WaitForOptions = { + intervalMs?: number; + timeoutMs?: number; +}; + +/** + * Testing Utility - waitFor. Waits for and checks (at an interval) if assertion is reached. + * + * @param assertionFn - assertion function + * @param options - set wait for options + * @returns promise that you need to await in tests + */ +export const waitFor = async ( + assertionFn: () => void, + options: WaitForOptions = {}, +): Promise => { + const { intervalMs = 50, timeoutMs = 2000 } = options; + + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + let lastError: unknown; + const intervalId = setInterval(() => { + try { + assertionFn(); + clearInterval(intervalId); + resolve(); + } catch (error) { + lastError = error; + if (Date.now() - startTime >= timeoutMs) { + clearInterval(intervalId); + const assertionDetail = + lastError instanceof Error ? lastError.message : String(lastError); + reject( + new Error( + `waitFor: timeout reached after ${timeoutMs}ms. Last assertion error: ${assertionDetail}`, + ), + ); + } + } + }, intervalMs); + }); +}; diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts index d4fa0785d8c..dcaf4dfacf9 100644 --- a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.test.ts @@ -1,7 +1,42 @@ +import { cloneDeep } from 'lodash'; + +import { + createTestApiClient, + mockSuggestedOccurrenceFloors, + mockV3Assets, +} from '../__fixtures__/mockTokenApi.js'; +import { + ACCOUNT_ONE_ID, + ACCOUNT_TWO_ID, + ARBITRUM_GMX, + BASE_FARTCOIN, + BASE_SPAM, + BASE_USDC, + MAINNET_NATIVE, + MAINNET_USDT, + MONAD_WMON, + OPTIMISM_SPAM, + OPTIMISM_USDC, + OUT_OF_SCOPE_ASSET_IDS, + SEI_USDCN, + SPAM_ASSET_IDS, + SPAM_WALLET_ASSETS_INFO, + SURVIVING_ASSET_IDS, + SWEEPABLE_ASSET_IDS, + buildAssetsInfo, + buildLowercasedSpamWalletState, + buildManyTokensState, + buildSpamWalletState, +} from '../__fixtures__/spamWalletState.js'; import type { AssetsControllerStateInternal, Caip19AssetId } from '../types.js'; -import type { CurrentAssetsState } from './healAssetsInfoMetadata.js'; +import type { + CleanSpamAssetsState, + CurrentAssetsState, +} from './healAssetsInfoMetadata.js'; import { + cleanSpamAssets, healAssetsInfoMetadata, + isUnlockCleanupEnabled, tempHealAssetsInfoMetadata, } from './healAssetsInfoMetadata.js'; @@ -627,3 +662,352 @@ describe('tempHealAssetsInfoMetadata', () => { }).not.toThrow(); }); }); + +describe('isUnlockCleanupEnabled', () => { + it('returns true when the assetsUnifyState flag sets useUnlockCleanup to true', () => { + expect( + isUnlockCleanupEnabled({ assetsUnifyState: { useUnlockCleanup: true } }), + ).toBe(true); + }); + + it.each([ + [ + 'useUnlockCleanup explicitly false', + { assetsUnifyState: { useUnlockCleanup: false } }, + ], + [ + 'useUnlockCleanup missing from the flag', + { assetsUnifyState: { enabled: true, featureVersion: '1' } }, + ], + [ + 'useUnlockCleanup a string', + { assetsUnifyState: { useUnlockCleanup: 'true' } }, + ], + [ + 'useUnlockCleanup a number', + { assetsUnifyState: { useUnlockCleanup: 1 } }, + ], + ['assetsUnifyState flag missing', {}], + ['assetsUnifyState flag null', { assetsUnifyState: null }], + ['assetsUnifyState flag not an object', { assetsUnifyState: true }], + ['flags state undefined', undefined], + ['flags state null', null], + ['flags state not an object', true], + ['flags state a string', 'assetsUnifyState'], + ])('returns false when %s', (_caseName, remoteFeatureFlags) => { + expect(isUnlockCleanupEnabled(remoteFeatureFlags)).toBe(false); + }); +}); + +describe('cleanSpamAssets', () => { + /** + * Run the sweep and apply the resulting patch to a copy of the state, the + * way the controller applies it inside `update`. + * + * @param state - Current controller state (never mutated). + * @param captureException - Optional reporter for failures. + * @returns The cleaned state copy, or the original state when the sweep has + * nothing to remove or fails. + */ + async function runCleanup( + state: CleanSpamAssetsState, + captureException?: (error: Error) => void, + ): Promise { + const result = await cleanSpamAssets({ + state, + apiClient: createTestApiClient(), + captureException, + }); + if (!result) { + return state; + } + const nextState = cloneDeep(state); + result.applyPatch(nextState, { spamAssetIds: result.spamAssetIds }); + return nextState; + } + + function removedAssetIds( + before: CleanSpamAssetsState, + after: CleanSpamAssetsState, + ): string[] { + const remaining = new Set(Object.keys(after.assetsInfo)); + return Object.keys(before.assetsInfo) + .filter((assetId) => !remaining.has(assetId)) + .sort(); + } + + const classificationCases: { + description: string; + held: Caip19AssetId[]; + omittedFromResponse?: Caip19AssetId[]; + removed: Caip19AssetId[]; + casing?: 'lowercase' | 'checksum'; + }[] = [ + { + description: 'drops every spam token and keeps every genuine one', + held: [...SURVIVING_ASSET_IDS, ...SPAM_ASSET_IDS], + removed: SPAM_ASSET_IDS, + }, + { + // Both tokens appear in two lists. Sei's suggested floor is one, and + // Optimism, which the endpoint does not cover, falls back to three. + description: + 'spares a thinly listed token on a chain the API sets a lower floor for', + held: [SEI_USDCN, OPTIMISM_SPAM], + removed: [OPTIMISM_SPAM], + }, + { + // Fartcoin sits exactly on the fallback floor, the airdrop one below it. + description: + 'falls back to a floor of three on chains the API does not cover', + held: [BASE_FARTCOIN, OPTIMISM_SPAM], + removed: [OPTIMISM_SPAM], + }, + { + // The API answers for these with an empty stub rather than a real entry. + description: 'drops a token the API has never indexed', + held: [BASE_SPAM], + removed: [BASE_SPAM], + }, + { + // Which is how it answers for every Monad token today, so a real Wrapped + // MON holding is swept along with the spam. + description: + 'drops a token the API describes but reports no occurrence count for', + held: [MONAD_WMON], + removed: [MONAD_WMON], + }, + { + description: 'drops a token the API leaves out of its response', + held: [MAINNET_USDT, OPTIMISM_USDC], + omittedFromResponse: [MAINNET_USDT], + removed: [MAINNET_USDT], + }, + { + // State keys are EIP-55 checksummed; the API answers in lowercase. + description: 'matches the API response to state keys case-insensitively', + held: [OPTIMISM_USDC], + removed: [], + }, + { + // State keys are lowercase; the API answers in EIP-55 checksummed. + description: + 'matches the API response to state keys case-insensitively when API answers in checksummed IDs', + held: [OPTIMISM_USDC.toLowerCase() as Caip19AssetId], + removed: [], + casing: 'checksum', + }, + ]; + + it.each(classificationCases)( + '$description', + async ({ held, omittedFromResponse, removed, casing }) => { + mockSuggestedOccurrenceFloors(); + mockV3Assets({ omit: omittedFromResponse, casing }); + const state = buildSpamWalletState({ assetsInfo: buildAssetsInfo(held) }); + + const nextState = await runCleanup(state); + + expect(removedAssetIds(state, nextState)).toStrictEqual( + [...removed].sort(), + ); + }, + ); + + it('sweeps a wallet whose EVM asset IDs were never checksummed', async () => { + // The wallet is keyed in lowercase but still imported GMX under its + // checksummed ID, so the sweep has to hold that one back on a casing its + // `assetsInfo` does not share. + mockSuggestedOccurrenceFloors(); + const { requestedBatches } = mockV3Assets(); + const state = buildLowercasedSpamWalletState(); + const lowercasedSpamAssetIds = SPAM_ASSET_IDS.map((assetId) => + assetId.toLowerCase(), + ); + + const nextState = await runCleanup(state); + + expect([...requestedBatches[0]].sort()).toStrictEqual( + SWEEPABLE_ASSET_IDS.map((assetId) => assetId.toLowerCase()).sort(), + ); + expect(removedAssetIds(state, nextState)).toStrictEqual( + [...lowercasedSpamAssetIds].sort(), + ); + expect( + Object.values(nextState.assetsBalance).flatMap((balances) => + Object.keys(balances), + ), + ).toStrictEqual(expect.not.arrayContaining(lowercasedSpamAssetIds)); + }); + + it('sweeps a wallet whose EVM asset IDs were never checksummed when the API answers in checksummed IDs', async () => { + mockSuggestedOccurrenceFloors(); + const { requestedBatches } = mockV3Assets({ casing: 'checksum' }); + const state = buildLowercasedSpamWalletState(); + const lowercasedSpamAssetIds = SPAM_ASSET_IDS.map((assetId) => + assetId.toLowerCase(), + ); + + const nextState = await runCleanup(state); + + expect([...requestedBatches[0]].sort()).toStrictEqual( + SWEEPABLE_ASSET_IDS.map((assetId) => assetId.toLowerCase()).sort(), + ); + expect(removedAssetIds(state, nextState)).toStrictEqual( + [...lowercasedSpamAssetIds].sort(), + ); + expect( + Object.values(nextState.assetsBalance).flatMap((balances) => + Object.keys(balances), + ), + ).toStrictEqual(expect.not.arrayContaining(lowercasedSpamAssetIds)); + }); + + it('leaves native, non-EVM, niche-chain, default-tracked, and imported assets out of the sweep', async () => { + mockSuggestedOccurrenceFloors(); + const { requestedBatches } = mockV3Assets(); + const state = buildSpamWalletState(); + + const nextState = await runCleanup(state); + + expect([...requestedBatches[0]].sort()).toStrictEqual( + [...SWEEPABLE_ASSET_IDS].sort(), + ); + for (const assetId of OUT_OF_SCOPE_ASSET_IDS) { + expect(nextState.assetsInfo[assetId]).toStrictEqual( + SPAM_WALLET_ASSETS_INFO[assetId], + ); + } + }); + + it('makes no network calls when nothing is in scope', async () => { + const floorsScope = mockSuggestedOccurrenceFloors(); + const { scope: assetsScope } = mockV3Assets(); + const state = buildSpamWalletState({ + assetsInfo: buildAssetsInfo(OUT_OF_SCOPE_ASSET_IDS), + }); + + const nextState = await runCleanup(state); + + expect(nextState).toBe(state); + expect(floorsScope.isDone()).toBe(false); + expect(assetsScope.isDone()).toBe(false); + }); + + it('returns the state it was given when every token clears its floor', async () => { + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + const state = buildSpamWalletState({ + assetsInfo: buildAssetsInfo([MAINNET_USDT, OPTIMISM_USDC, BASE_USDC]), + }); + + const nextState = await runCleanup(state); + + expect(nextState).toBe(state); + }); + + it('stops tracking a spam token for every account holding it', async () => { + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + const state = buildSpamWalletState(); + + const nextState = await runCleanup(state); + + expect(nextState.assetsBalance).toStrictEqual({ + [ACCOUNT_ONE_ID]: { + [MAINNET_NATIVE]: { amount: '1204500000000000000' }, + [MAINNET_USDT]: { amount: '2500000000' }, + [OPTIMISM_USDC]: { amount: '148230000' }, + [SEI_USDCN]: { amount: '74500000' }, + }, + [ACCOUNT_TWO_ID]: { + [BASE_FARTCOIN]: { amount: '1200000000000000000000' }, + [ARBITRUM_GMX]: { amount: '3400000000000000000' }, + }, + }); + expect(nextState.customAssets).toStrictEqual(state.customAssets); + }); + + it('does not mutate the state it was given', async () => { + mockSuggestedOccurrenceFloors(); + mockV3Assets(); + const state = buildSpamWalletState(); + const snapshot = structuredClone(state); + + await cleanSpamAssets({ state, apiClient: createTestApiClient() }); + + expect(state).toStrictEqual(snapshot); + }); + + it('refetches occurrence data on every sweep rather than reusing the cache', async () => { + // Cached counts would classify the wallet against a stale token list. + mockSuggestedOccurrenceFloors({ times: 2 }); + const { requestedBatches } = mockV3Assets({ times: 2 }); + const state = buildSpamWalletState({ + assetsInfo: buildAssetsInfo([MAINNET_USDT]), + }); + const apiClient = createTestApiClient(); + + await cleanSpamAssets({ state, apiClient }); + await cleanSpamAssets({ state, apiClient }); + + expect(requestedBatches).toStrictEqual([[MAINNET_USDT], [MAINNET_USDT]]); + }); + + it('sweeps large wallets in batches of 50', async () => { + const { state } = buildManyTokensState(51); + mockSuggestedOccurrenceFloors(); + const { requestedBatches } = mockV3Assets({ + occurrences: {}, + times: 2, + }); + + const nextState = await runCleanup(state); + + expect(requestedBatches.map((batch) => batch.length)).toStrictEqual([ + 50, 1, + ]); + expect(nextState.assetsInfo).toStrictEqual({}); + }); + + it('finishes the sweep when a single batch fails', async () => { + const { assetIds, state } = buildManyTokensState(101); + mockSuggestedOccurrenceFloors(); + mockV3Assets({ occurrences: {} }); + mockV3Assets({ status: 502 }); + mockV3Assets({ occurrences: {} }); + const captureException = jest.fn(); + + const nextState = await runCleanup(state); + + // The 50 assets in the failed batch stay put; the other 51 are dropped. + expect(Object.keys(nextState.assetsInfo)).toStrictEqual( + assetIds.slice(50, 100), + ); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('leaves every asset in place and reports when the floors endpoint fails', async () => { + mockSuggestedOccurrenceFloors({ status: 503 }); + const { scope: assetsScope } = mockV3Assets(); + const state = buildSpamWalletState(); + const captureException = jest.fn(); + + const nextState = await runCleanup(state, captureException); + + expect(nextState).toBe(state); + expect(assetsScope.isDone()).toBe(false); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('503'), + }), + ); + }); + + it('swallows failures when no captureException is provided', async () => { + mockSuggestedOccurrenceFloors({ status: 503 }); + const state = buildSpamWalletState(); + + expect(await runCleanup(state)).toBe(state); + }); +}); diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts index 5546a7faefe..6845d275511 100644 --- a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts @@ -1,5 +1,6 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { TokensControllerState } from '@metamask/assets-controllers'; +import type { ApiPlatformClient } from '@metamask/core-backend'; import type { Hex } from '@metamask/utils'; import { getChecksumAddress, @@ -9,6 +10,8 @@ import { } from '@metamask/utils'; import { cloneDeep } from 'lodash'; +import { divideIntoBatches } from '../data-sources/evm-rpc-services/utils/batch.js'; +import { DEFAULT_TRACKED_ASSETS_BY_CHAIN } from '../defaults.js'; import { createModuleLogger, projectLogger } from '../logger.js'; import type { AccountId, @@ -16,6 +19,7 @@ import type { FungibleAssetMetadata, AssetsControllerStateInternal, } from '../types.js'; +import { fetchWithTimeout } from '../utils/fetchWithTimeout.js'; /** * TEMPORARY MODULE — remove in a future release. @@ -584,3 +588,240 @@ function addCustomAssetAddition( } additions[accountId] = queued; } + +const cleanupLog = createModuleLogger(projectLogger, 'cleanSpamAssets'); + +/** + * TEMPORARY — feature flag for unlock spam cleanup + * + * @param remoteFeatureFlags - RemoteFeatureFlag state. + * @returns `true` only when the flag explicitly enables the cleanup. + */ +export function isUnlockCleanupEnabled(remoteFeatureFlags: unknown): boolean { + if (!isObject(remoteFeatureFlags)) { + return false; + } + + const flag = remoteFeatureFlags.assetsUnifyState; + return ( + isObject(flag) && + hasProperty(flag, 'useUnlockCleanup') && + flag.useUnlockCleanup === true + ); +} + +const FETCH_TIMEOUT_MS = 15_000; +const DEFAULT_OCCURRENCE_FLOOR = 3; + +/** The slices of Token API surface the cleanup uses. */ +export type SpamTokensApiClient = Pick; + +export type CleanSpamAssetsState = Pick< + AssetsControllerStateInternal, + 'assetsInfo' | 'assetsBalance' | 'customAssets' +>; + +export type CleanSpamAssetsOptions = { + state: CleanSpamAssetsState; + apiClient: SpamTokensApiClient; + captureException?: (error: Error) => void; +}; + +/** + * Cleanup of Spam Assets. + * The state can be persisted with spam tokens through boundary faults (API loosened, WS passing in spam) + * + * @param options - Input params. + * @param options.state - Current controller state (read-only). + * @param options.apiClient - Token API client. + * @param options.captureException - Optional reporter for failures. + * @returns Updated or original controller state + */ +export async function cleanSpamAssets({ + state, + apiClient, + captureException, +}: CleanSpamAssetsOptions): Promise<{ + spamAssetIds: Caip19AssetId[]; + applyPatch: typeof applyCleanupPatch; +} | null> { + const candidates = collectSpamCleanupCandidates(state); + if (candidates.length === 0) { + return null; + } + + try { + const floors = await fetchSuggestedOccurrenceFloors(apiClient); + const spamAssetIds: Caip19AssetId[] = []; + + for (const batch of divideIntoBatches(candidates, { + batchSize: 50, + })) { + const belowFloorAssetIds = await findBelowFloorAssetIds( + batch, + floors, + apiClient, + ).catch(() => []); + if (belowFloorAssetIds.length > 0) { + cleanupLog('Classified a batch of assets as spam', { + batchSize: batch.length, + spamCount: belowFloorAssetIds.length, + }); + spamAssetIds.push(...belowFloorAssetIds); + } + } + + if (spamAssetIds.length === 0) { + return null; + } + + return { spamAssetIds, applyPatch: applyCleanupPatch }; + } catch (error) { + // API calls failed, cleanup not performed. Will be retried when next invoked. + cleanupLog( + 'Spam cleanup failed; leaving remaining assets untouched', + error, + ); + captureException?.( + new Error( + `AssetsController: assetsInfo spam cleanup failed: ${getErrorMessage( + error, + )}`, + ), + ); + return null; + } +} + +/** + * Collect the assets eligible for cleanup: + * - ERC-20 token + * - Not in excluded list + * - Not a custom asset + * - On Account API covered chains + * + * @param state - Current controller state. + * @param state.assetsInfo - Tracked asset metadata, keyed by CAIP-19 ID. + * @param state.customAssets - Per-account custom asset IDs. + * @returns Candidate asset IDs. + */ +function collectSpamCleanupCandidates({ + assetsInfo, + customAssets, +}: Pick): Caip19AssetId[] { + const customAssetIds = new Set( + Object.values(customAssets) + .flat() + .map((assetId) => assetId.toLowerCase()), + ); + + const excludedAssets = [...DEFAULT_TRACKED_ASSETS_BY_CHAIN.values()] + .flat() + .map((a) => a.toLowerCase()); + + return (Object.keys(assetsInfo) as Caip19AssetId[]).filter((assetId) => { + const lowerId = assetId.toLowerCase(); + const [chainId, asset] = lowerId.split('/'); + + const isERC20 = Boolean(asset?.startsWith('erc20:')); + const isNotExcluded = !excludedAssets.includes(lowerId); + const isNotCustomAsset = !customAssetIds.has(lowerId); + const isOnAccountAPICoveredChain = + ACCOUNT_API_SUPPORTED_CHAIN_IDS.has(chainId); + + return ( + isERC20 && isNotExcluded && isNotCustomAsset && isOnAccountAPICoveredChain + ); + }); +} + +/** + * Fetch and filter assets by occurrence floor. + * If fetch fails, error is propagated (fail-close, cleanup not performed) + * + * @param assetIds - A single batch of candidate asset IDs. + * @param floors - Suggested occurrence floors keyed by decimal chain ID. + * @param apiClient - Token API client. + * @returns The subset of `assetIds` that falls below its chain's floor. + */ +async function findBelowFloorAssetIds( + assetIds: Caip19AssetId[], + floors: Record, + apiClient: SpamTokensApiClient, +): Promise { + try { + const assets = await fetchWithTimeout( + () => + apiClient.tokens.fetchV3Assets( + assetIds, + { includeOccurrences: true }, + { staleTime: 0, gcTime: 0 }, + ), + FETCH_TIMEOUT_MS, + ); + + const occurrencesByLowerId = new Map( + assets.map((asset) => [asset.assetId.toLowerCase(), asset.occurrences]), + ); + + return assetIds.filter((assetId) => { + const chainReference = assetId.split(':')[1]?.split('/')[0] ?? ''; + const floor = floors[chainReference] ?? DEFAULT_OCCURRENCE_FLOOR; + return (occurrencesByLowerId.get(assetId.toLowerCase()) ?? 0) < floor; + }); + } catch (error) { + cleanupLog('Failed to fetch assets', error); + throw error; + } +} + +/** + * Fetch per-chain suggested occurrence floors. + * If fetch fails, error is propagated (fail-close, cleanup not performed) + * + * @param apiClient - Token API client. + * @returns Map of decimal chain ID to suggested floor. + */ +async function fetchSuggestedOccurrenceFloors( + apiClient: SpamTokensApiClient, +): Promise> { + try { + return await fetchWithTimeout( + () => + apiClient.token.fetchV1SuggestedOccurrenceFloors({ + staleTime: 0, + gcTime: 0, + }), + FETCH_TIMEOUT_MS, + ); + } catch (error) { + cleanupLog('Failed to fetch suggested occurrence floors', error); + throw error; + } +} + +function applyCleanupPatch( + state: Pick, + patch: { + spamAssetIds: Caip19AssetId[]; + }, +): void { + const { spamAssetIds } = patch; + + const spam = new Set(spamAssetIds.map((assetId) => assetId.toLowerCase())); + if (spam.size === 0) { + return; + } + const isSpam = (assetId: string): boolean => spam.has(assetId.toLowerCase()); + + for (const assetId of Object.keys(state.assetsInfo).filter(isSpam)) { + delete state.assetsInfo[assetId as Caip19AssetId]; + } + for (const balances of Object.values(state.assetsBalance)) { + for (const assetId of Object.keys(balances).filter(isSpam)) { + delete balances[assetId as Caip19AssetId]; + } + } + + cleanupLog('Removed spam assets', { count: spam.size }); +} diff --git a/yarn.lock b/yarn.lock index 4acf111e5a8..7cb837b10ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6019,6 +6019,7 @@ __metadata: deepmerge: "npm:^4.2.2" jest: "npm:^30.4.2" lodash: "npm:^4.17.21" + nock: "npm:^13.3.1" p-limit: "npm:^3.1.0" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5"