@metamask/assets-controller 6.4.0 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/dist/AssetsController.cjs +33 -42
  3. package/dist/AssetsController.cjs.map +1 -1
  4. package/dist/AssetsController.d.cts +2 -2
  5. package/dist/AssetsController.d.cts.map +1 -1
  6. package/dist/AssetsController.d.mts +2 -2
  7. package/dist/AssetsController.d.mts.map +1 -1
  8. package/dist/AssetsController.mjs +33 -42
  9. package/dist/AssetsController.mjs.map +1 -1
  10. package/dist/data-sources/RpcDataSource.cjs +46 -25
  11. package/dist/data-sources/RpcDataSource.cjs.map +1 -1
  12. package/dist/data-sources/RpcDataSource.d.cts +12 -0
  13. package/dist/data-sources/RpcDataSource.d.cts.map +1 -1
  14. package/dist/data-sources/RpcDataSource.d.mts +12 -0
  15. package/dist/data-sources/RpcDataSource.d.mts.map +1 -1
  16. package/dist/data-sources/RpcDataSource.mjs +46 -25
  17. package/dist/data-sources/RpcDataSource.mjs.map +1 -1
  18. package/dist/data-sources/TokenDataSource.cjs +12 -5
  19. package/dist/data-sources/TokenDataSource.cjs.map +1 -1
  20. package/dist/data-sources/TokenDataSource.d.cts.map +1 -1
  21. package/dist/data-sources/TokenDataSource.d.mts.map +1 -1
  22. package/dist/data-sources/TokenDataSource.mjs +12 -5
  23. package/dist/data-sources/TokenDataSource.mjs.map +1 -1
  24. package/dist/data-sources/evm-rpc-services/clients/TokensApiClient.cjs +124 -32
  25. package/dist/data-sources/evm-rpc-services/clients/TokensApiClient.cjs.map +1 -1
  26. package/dist/data-sources/evm-rpc-services/clients/TokensApiClient.d.cts +31 -3
  27. package/dist/data-sources/evm-rpc-services/clients/TokensApiClient.d.cts.map +1 -1
  28. package/dist/data-sources/evm-rpc-services/clients/TokensApiClient.d.mts +31 -3
  29. package/dist/data-sources/evm-rpc-services/clients/TokensApiClient.d.mts.map +1 -1
  30. package/dist/data-sources/evm-rpc-services/clients/TokensApiClient.mjs +124 -32
  31. package/dist/data-sources/evm-rpc-services/clients/TokensApiClient.mjs.map +1 -1
  32. package/dist/data-sources/evm-rpc-services/clients/index.cjs.map +1 -1
  33. package/dist/data-sources/evm-rpc-services/clients/index.d.cts +1 -1
  34. package/dist/data-sources/evm-rpc-services/clients/index.d.cts.map +1 -1
  35. package/dist/data-sources/evm-rpc-services/clients/index.d.mts +1 -1
  36. package/dist/data-sources/evm-rpc-services/clients/index.d.mts.map +1 -1
  37. package/dist/data-sources/evm-rpc-services/clients/index.mjs.map +1 -1
  38. package/dist/data-sources/evm-rpc-services/index.cjs.map +1 -1
  39. package/dist/data-sources/evm-rpc-services/index.d.cts +1 -1
  40. package/dist/data-sources/evm-rpc-services/index.d.cts.map +1 -1
  41. package/dist/data-sources/evm-rpc-services/index.d.mts +1 -1
  42. package/dist/data-sources/evm-rpc-services/index.d.mts.map +1 -1
  43. package/dist/data-sources/evm-rpc-services/index.mjs.map +1 -1
  44. package/dist/data-sources/evm-rpc-services/services/TokenDetector.cjs.map +1 -1
  45. package/dist/data-sources/evm-rpc-services/services/TokenDetector.d.cts.map +1 -1
  46. package/dist/data-sources/evm-rpc-services/services/TokenDetector.d.mts.map +1 -1
  47. package/dist/data-sources/evm-rpc-services/services/TokenDetector.mjs.map +1 -1
  48. package/dist/middlewares/CustomAssetGraduationMiddleware.cjs +32 -4
  49. package/dist/middlewares/CustomAssetGraduationMiddleware.cjs.map +1 -1
  50. package/dist/middlewares/CustomAssetGraduationMiddleware.d.cts +11 -4
  51. package/dist/middlewares/CustomAssetGraduationMiddleware.d.cts.map +1 -1
  52. package/dist/middlewares/CustomAssetGraduationMiddleware.d.mts +11 -4
  53. package/dist/middlewares/CustomAssetGraduationMiddleware.d.mts.map +1 -1
  54. package/dist/middlewares/CustomAssetGraduationMiddleware.mjs +32 -4
  55. package/dist/middlewares/CustomAssetGraduationMiddleware.mjs.map +1 -1
  56. package/dist/utils/customAssetsRpcSupplement.cjs +72 -0
  57. package/dist/utils/customAssetsRpcSupplement.cjs.map +1 -0
  58. package/dist/utils/customAssetsRpcSupplement.d.cts +60 -0
  59. package/dist/utils/customAssetsRpcSupplement.d.cts.map +1 -0
  60. package/dist/utils/customAssetsRpcSupplement.d.mts +60 -0
  61. package/dist/utils/customAssetsRpcSupplement.d.mts.map +1 -0
  62. package/dist/utils/customAssetsRpcSupplement.mjs +68 -0
  63. package/dist/utils/customAssetsRpcSupplement.mjs.map +1 -0
  64. package/package.json +13 -13
@@ -21,15 +21,22 @@ const CONTROLLER_NAME = 'CustomAssetGraduationMiddleware';
21
21
  const log = (0, logger_1.createModuleLogger)(logger_1.projectLogger, CONTROLLER_NAME);
22
22
  /**
23
23
  * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when
24
- * an upstream balance source (AccountsAPI / Websocket) reports a balance for
25
- * them. Once a detector sees the asset, it no longer needs to be tracked as
26
- * "custom" — the regular detection flow will keep it fresh.
24
+ * an upstream balance source (AccountsAPI / Websocket) reports a non-zero
25
+ * balance for them. Once a detector sees the asset with a real balance, it
26
+ * no longer needs to be tracked as "custom" — the regular detection flow
27
+ * will keep it fresh.
27
28
  *
28
29
  * Rules:
29
- * - Only the selected account's custom assets are considered.
30
+ * - Only the selected account's custom assets are considered. Switching the
31
+ * selected account triggers a fresh fetch, which re-runs graduation
32
+ * against the new account's balances.
30
33
  * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom
31
34
  * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left
32
35
  * alone.
36
+ * - Only positive balances graduate. A zero balance from AccountsAPI means
37
+ * the API knows about the token but the user does not currently hold it;
38
+ * keeping it in `customAssets` ensures RPC keeps polling so a future
39
+ * incoming transfer is reflected promptly.
33
40
  */
34
41
  class CustomAssetGraduationMiddleware {
35
42
  constructor(options) {
@@ -73,6 +80,9 @@ class CustomAssetGraduationMiddleware {
73
80
  if (!isEvmAssetId(rawAssetId)) {
74
81
  continue;
75
82
  }
83
+ if (!hasPositiveBalance(returnedBalances[rawAssetId])) {
84
+ continue;
85
+ }
76
86
  const normalizedAssetId = safeNormalize(rawAssetId);
77
87
  if (!customSet.has(normalizedAssetId)) {
78
88
  continue;
@@ -118,4 +128,22 @@ function safeNormalize(assetId) {
118
128
  return assetId;
119
129
  }
120
130
  }
131
+ /**
132
+ * Whether a balance entry reports a strictly positive amount. AccountsAPI
133
+ * may return zero for tokens it indexes but the user no longer holds; we
134
+ * treat those as non-graduating so RPC keeps polling and surfaces any
135
+ * future incoming transfer immediately.
136
+ *
137
+ * `AssetBalance.amount` is already a human-readable decimal string from
138
+ * both AccountsApi (e.g. "0.283549083429656057") and the websocket data
139
+ * source (which divides by `decimals` before emitting), so a `Number()`
140
+ * sign check is safe: `NaN`, `undefined`, empty strings, and zero all
141
+ * fail the comparison.
142
+ *
143
+ * @param balance - The balance entry from the response.
144
+ * @returns `true` when the balance amount represents a value greater than 0.
145
+ */
146
+ function hasPositiveBalance(balance) {
147
+ return Number(balance?.amount) > 0;
148
+ }
121
149
  //# sourceMappingURL=CustomAssetGraduationMiddleware.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"CustomAssetGraduationMiddleware.cjs","sourceRoot":"","sources":["../../src/middlewares/CustomAssetGraduationMiddleware.ts"],"names":[],"mappings":"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","sourcesContent":["import { KnownCaipNamespace } from '@metamask/utils';\n\nimport { projectLogger, createModuleLogger } from '../logger';\nimport { forDataTypes } from '../types';\nimport type { AccountId, Caip19AssetId, Middleware } from '../types';\nimport { normalizeAssetId } from '../utils';\n\nconst CONTROLLER_NAME = 'CustomAssetGraduationMiddleware';\n\nconst log = createModuleLogger(projectLogger, CONTROLLER_NAME);\n\nexport type CustomAssetGraduationMiddlewareOptions = {\n getSelectedAccountId: () => AccountId | undefined;\n removeCustomAsset: (accountId: AccountId, assetId: Caip19AssetId) => void;\n};\n\n/**\n * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when\n * an upstream balance source (AccountsAPI / Websocket) reports a balance for\n * them. Once a detector sees the asset, it no longer needs to be tracked as\n * \"custom\" — the regular detection flow will keep it fresh.\n *\n * Rules:\n * - Only the selected account's custom assets are considered.\n * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom\n * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left\n * alone.\n */\nexport class CustomAssetGraduationMiddleware {\n readonly name = CONTROLLER_NAME;\n\n readonly #getSelectedAccountId: () => AccountId | undefined;\n\n readonly #removeCustomAsset: (\n accountId: AccountId,\n assetId: Caip19AssetId,\n ) => void;\n\n constructor(options: CustomAssetGraduationMiddlewareOptions) {\n this.#getSelectedAccountId = options.getSelectedAccountId;\n this.#removeCustomAsset = options.removeCustomAsset;\n }\n\n getName(): string {\n return this.name;\n }\n\n get assetsMiddleware(): Middleware {\n return forDataTypes(['balance'], async (ctx, next) => {\n // Inspect the response BEFORE calling next() so we only consider\n // balances populated by upstream middleware (AccountsApi / Websocket /\n // Staked). This middleware is positioned in the pipeline before the\n // RPC fallback — RPC intentionally carries custom assets and must\n // never trigger graduation.\n const accountId = this.#getSelectedAccountId();\n if (!accountId) {\n return next(ctx);\n }\n\n const state = ctx.getAssetsState();\n const customForAccount = state.customAssets?.[accountId] ?? [];\n if (customForAccount.length === 0) {\n return next(ctx);\n }\n\n const returnedBalances = ctx.response.assetsBalance?.[accountId] ?? {};\n const returnedAssetIds = Object.keys(returnedBalances) as Caip19AssetId[];\n if (returnedAssetIds.length === 0) {\n return next(ctx);\n }\n\n // customAssets state is stored with checksummed/normalized asset IDs.\n // AccountsApiDataSource normalizes its response IDs, but\n // BackendWebsocketDataSource does not — so we normalize the response\n // side here to make the comparison robust to lower-case addresses\n // delivered over the websocket.\n const customSet = new Set(customForAccount);\n for (const rawAssetId of returnedAssetIds) {\n if (!isEvmAssetId(rawAssetId)) {\n continue;\n }\n const normalizedAssetId = safeNormalize(rawAssetId);\n if (!customSet.has(normalizedAssetId)) {\n continue;\n }\n log('Graduating custom asset', {\n accountId,\n assetId: normalizedAssetId,\n });\n this.#removeCustomAsset(accountId, normalizedAssetId);\n }\n\n return next(ctx);\n });\n }\n}\n\n/**\n * Check whether a CAIP-19 asset ID belongs to an EVM chain.\n *\n * @param assetId - The CAIP-19 asset ID to inspect.\n * @returns `true` when the asset's chain namespace is `eip155`.\n */\nfunction isEvmAssetId(assetId: Caip19AssetId): boolean {\n // CAIP-19 format: <namespace>:<chainRef>/<assetNamespace>:<assetRef>\n // The chain namespace is always the segment before the first colon.\n const namespace = assetId.split(':')[0];\n return namespace === KnownCaipNamespace.Eip155;\n}\n\n/**\n * Normalize a CAIP-19 asset ID, returning the original on failure. Some\n * malformed IDs (e.g. an asset reference that fails address checksumming)\n * make `normalizeAssetId` throw — in that case we fall back to the raw ID\n * so the graduation pass can still proceed for other assets.\n *\n * @param assetId - The CAIP-19 asset ID to normalize.\n * @returns The normalized ID, or the original on failure.\n */\nfunction safeNormalize(assetId: Caip19AssetId): Caip19AssetId {\n try {\n return normalizeAssetId(assetId);\n } catch {\n return assetId;\n }\n}\n"]}
1
+ {"version":3,"file":"CustomAssetGraduationMiddleware.cjs","sourceRoot":"","sources":["../../src/middlewares/CustomAssetGraduationMiddleware.ts"],"names":[],"mappings":"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","sourcesContent":["import { KnownCaipNamespace } from '@metamask/utils';\n\nimport { projectLogger, createModuleLogger } from '../logger';\nimport { forDataTypes } from '../types';\nimport type {\n AccountId,\n AssetBalance,\n Caip19AssetId,\n Middleware,\n} from '../types';\nimport { normalizeAssetId } from '../utils';\n\nconst CONTROLLER_NAME = 'CustomAssetGraduationMiddleware';\n\nconst log = createModuleLogger(projectLogger, CONTROLLER_NAME);\n\nexport type CustomAssetGraduationMiddlewareOptions = {\n getSelectedAccountId: () => AccountId | undefined;\n removeCustomAsset: (accountId: AccountId, assetId: Caip19AssetId) => void;\n};\n\n/**\n * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when\n * an upstream balance source (AccountsAPI / Websocket) reports a non-zero\n * balance for them. Once a detector sees the asset with a real balance, it\n * no longer needs to be tracked as \"custom\" — the regular detection flow\n * will keep it fresh.\n *\n * Rules:\n * - Only the selected account's custom assets are considered. Switching the\n * selected account triggers a fresh fetch, which re-runs graduation\n * against the new account's balances.\n * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom\n * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left\n * alone.\n * - Only positive balances graduate. A zero balance from AccountsAPI means\n * the API knows about the token but the user does not currently hold it;\n * keeping it in `customAssets` ensures RPC keeps polling so a future\n * incoming transfer is reflected promptly.\n */\nexport class CustomAssetGraduationMiddleware {\n readonly name = CONTROLLER_NAME;\n\n readonly #getSelectedAccountId: () => AccountId | undefined;\n\n readonly #removeCustomAsset: (\n accountId: AccountId,\n assetId: Caip19AssetId,\n ) => void;\n\n constructor(options: CustomAssetGraduationMiddlewareOptions) {\n this.#getSelectedAccountId = options.getSelectedAccountId;\n this.#removeCustomAsset = options.removeCustomAsset;\n }\n\n getName(): string {\n return this.name;\n }\n\n get assetsMiddleware(): Middleware {\n return forDataTypes(['balance'], async (ctx, next) => {\n // Inspect the response BEFORE calling next() so we only consider\n // balances populated by upstream middleware (AccountsApi / Websocket /\n // Staked). This middleware is positioned in the pipeline before the\n // RPC fallback — RPC intentionally carries custom assets and must\n // never trigger graduation.\n const accountId = this.#getSelectedAccountId();\n if (!accountId) {\n return next(ctx);\n }\n\n const state = ctx.getAssetsState();\n const customForAccount = state.customAssets?.[accountId] ?? [];\n if (customForAccount.length === 0) {\n return next(ctx);\n }\n\n const returnedBalances = ctx.response.assetsBalance?.[accountId] ?? {};\n const returnedAssetIds = Object.keys(returnedBalances) as Caip19AssetId[];\n if (returnedAssetIds.length === 0) {\n return next(ctx);\n }\n\n // customAssets state is stored with checksummed/normalized asset IDs.\n // AccountsApiDataSource normalizes its response IDs, but\n // BackendWebsocketDataSource does not — so we normalize the response\n // side here to make the comparison robust to lower-case addresses\n // delivered over the websocket.\n const customSet = new Set(customForAccount);\n for (const rawAssetId of returnedAssetIds) {\n if (!isEvmAssetId(rawAssetId)) {\n continue;\n }\n if (!hasPositiveBalance(returnedBalances[rawAssetId])) {\n continue;\n }\n const normalizedAssetId = safeNormalize(rawAssetId);\n if (!customSet.has(normalizedAssetId)) {\n continue;\n }\n log('Graduating custom asset', {\n accountId,\n assetId: normalizedAssetId,\n });\n this.#removeCustomAsset(accountId, normalizedAssetId);\n }\n\n return next(ctx);\n });\n }\n}\n\n/**\n * Check whether a CAIP-19 asset ID belongs to an EVM chain.\n *\n * @param assetId - The CAIP-19 asset ID to inspect.\n * @returns `true` when the asset's chain namespace is `eip155`.\n */\nfunction isEvmAssetId(assetId: Caip19AssetId): boolean {\n // CAIP-19 format: <namespace>:<chainRef>/<assetNamespace>:<assetRef>\n // The chain namespace is always the segment before the first colon.\n const namespace = assetId.split(':')[0];\n return namespace === KnownCaipNamespace.Eip155;\n}\n\n/**\n * Normalize a CAIP-19 asset ID, returning the original on failure. Some\n * malformed IDs (e.g. an asset reference that fails address checksumming)\n * make `normalizeAssetId` throw — in that case we fall back to the raw ID\n * so the graduation pass can still proceed for other assets.\n *\n * @param assetId - The CAIP-19 asset ID to normalize.\n * @returns The normalized ID, or the original on failure.\n */\nfunction safeNormalize(assetId: Caip19AssetId): Caip19AssetId {\n try {\n return normalizeAssetId(assetId);\n } catch {\n return assetId;\n }\n}\n\n/**\n * Whether a balance entry reports a strictly positive amount. AccountsAPI\n * may return zero for tokens it indexes but the user no longer holds; we\n * treat those as non-graduating so RPC keeps polling and surfaces any\n * future incoming transfer immediately.\n *\n * `AssetBalance.amount` is already a human-readable decimal string from\n * both AccountsApi (e.g. \"0.283549083429656057\") and the websocket data\n * source (which divides by `decimals` before emitting), so a `Number()`\n * sign check is safe: `NaN`, `undefined`, empty strings, and zero all\n * fail the comparison.\n *\n * @param balance - The balance entry from the response.\n * @returns `true` when the balance amount represents a value greater than 0.\n */\nfunction hasPositiveBalance(balance: AssetBalance | undefined): boolean {\n return Number(balance?.amount) > 0;\n}\n"]}
@@ -5,15 +5,22 @@ export type CustomAssetGraduationMiddlewareOptions = {
5
5
  };
6
6
  /**
7
7
  * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when
8
- * an upstream balance source (AccountsAPI / Websocket) reports a balance for
9
- * them. Once a detector sees the asset, it no longer needs to be tracked as
10
- * "custom" — the regular detection flow will keep it fresh.
8
+ * an upstream balance source (AccountsAPI / Websocket) reports a non-zero
9
+ * balance for them. Once a detector sees the asset with a real balance, it
10
+ * no longer needs to be tracked as "custom" — the regular detection flow
11
+ * will keep it fresh.
11
12
  *
12
13
  * Rules:
13
- * - Only the selected account's custom assets are considered.
14
+ * - Only the selected account's custom assets are considered. Switching the
15
+ * selected account triggers a fresh fetch, which re-runs graduation
16
+ * against the new account's balances.
14
17
  * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom
15
18
  * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left
16
19
  * alone.
20
+ * - Only positive balances graduate. A zero balance from AccountsAPI means
21
+ * the API knows about the token but the user does not currently hold it;
22
+ * keeping it in `customAssets` ensures RPC keeps polling so a future
23
+ * incoming transfer is reflected promptly.
17
24
  */
18
25
  export declare class CustomAssetGraduationMiddleware {
19
26
  #private;
@@ -1 +1 @@
1
- {"version":3,"file":"CustomAssetGraduationMiddleware.d.cts","sourceRoot":"","sources":["../../src/middlewares/CustomAssetGraduationMiddleware.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,UAAU,EAAE,qBAAiB;AAOrE,MAAM,MAAM,sCAAsC,GAAG;IACnD,oBAAoB,EAAE,MAAM,SAAS,GAAG,SAAS,CAAC;IAClD,iBAAiB,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,KAAK,IAAI,CAAC;CAC3E,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,qBAAa,+BAA+B;;IAC1C,QAAQ,CAAC,IAAI,qCAAmB;gBASpB,OAAO,EAAE,sCAAsC;IAK3D,OAAO,IAAI,MAAM;IAIjB,IAAI,gBAAgB,IAAI,UAAU,CA+CjC;CACF"}
1
+ {"version":3,"file":"CustomAssetGraduationMiddleware.d.cts","sourceRoot":"","sources":["../../src/middlewares/CustomAssetGraduationMiddleware.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,SAAS,EAET,aAAa,EACb,UAAU,EACX,qBAAiB;AAOlB,MAAM,MAAM,sCAAsC,GAAG;IACnD,oBAAoB,EAAE,MAAM,SAAS,GAAG,SAAS,CAAC;IAClD,iBAAiB,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,KAAK,IAAI,CAAC;CAC3E,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,+BAA+B;;IAC1C,QAAQ,CAAC,IAAI,qCAAmB;gBASpB,OAAO,EAAE,sCAAsC;IAK3D,OAAO,IAAI,MAAM;IAIjB,IAAI,gBAAgB,IAAI,UAAU,CAkDjC;CACF"}
@@ -5,15 +5,22 @@ export type CustomAssetGraduationMiddlewareOptions = {
5
5
  };
6
6
  /**
7
7
  * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when
8
- * an upstream balance source (AccountsAPI / Websocket) reports a balance for
9
- * them. Once a detector sees the asset, it no longer needs to be tracked as
10
- * "custom" — the regular detection flow will keep it fresh.
8
+ * an upstream balance source (AccountsAPI / Websocket) reports a non-zero
9
+ * balance for them. Once a detector sees the asset with a real balance, it
10
+ * no longer needs to be tracked as "custom" — the regular detection flow
11
+ * will keep it fresh.
11
12
  *
12
13
  * Rules:
13
- * - Only the selected account's custom assets are considered.
14
+ * - Only the selected account's custom assets are considered. Switching the
15
+ * selected account triggers a fresh fetch, which re-runs graduation
16
+ * against the new account's balances.
14
17
  * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom
15
18
  * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left
16
19
  * alone.
20
+ * - Only positive balances graduate. A zero balance from AccountsAPI means
21
+ * the API knows about the token but the user does not currently hold it;
22
+ * keeping it in `customAssets` ensures RPC keeps polling so a future
23
+ * incoming transfer is reflected promptly.
17
24
  */
18
25
  export declare class CustomAssetGraduationMiddleware {
19
26
  #private;
@@ -1 +1 @@
1
- {"version":3,"file":"CustomAssetGraduationMiddleware.d.mts","sourceRoot":"","sources":["../../src/middlewares/CustomAssetGraduationMiddleware.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,UAAU,EAAE,qBAAiB;AAOrE,MAAM,MAAM,sCAAsC,GAAG;IACnD,oBAAoB,EAAE,MAAM,SAAS,GAAG,SAAS,CAAC;IAClD,iBAAiB,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,KAAK,IAAI,CAAC;CAC3E,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,qBAAa,+BAA+B;;IAC1C,QAAQ,CAAC,IAAI,qCAAmB;gBASpB,OAAO,EAAE,sCAAsC;IAK3D,OAAO,IAAI,MAAM;IAIjB,IAAI,gBAAgB,IAAI,UAAU,CA+CjC;CACF"}
1
+ {"version":3,"file":"CustomAssetGraduationMiddleware.d.mts","sourceRoot":"","sources":["../../src/middlewares/CustomAssetGraduationMiddleware.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,SAAS,EAET,aAAa,EACb,UAAU,EACX,qBAAiB;AAOlB,MAAM,MAAM,sCAAsC,GAAG;IACnD,oBAAoB,EAAE,MAAM,SAAS,GAAG,SAAS,CAAC;IAClD,iBAAiB,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,KAAK,IAAI,CAAC;CAC3E,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,+BAA+B;;IAC1C,QAAQ,CAAC,IAAI,qCAAmB;gBASpB,OAAO,EAAE,sCAAsC;IAK3D,OAAO,IAAI,MAAM;IAIjB,IAAI,gBAAgB,IAAI,UAAU,CAkDjC;CACF"}
@@ -18,15 +18,22 @@ const CONTROLLER_NAME = 'CustomAssetGraduationMiddleware';
18
18
  const log = createModuleLogger(projectLogger, CONTROLLER_NAME);
19
19
  /**
20
20
  * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when
21
- * an upstream balance source (AccountsAPI / Websocket) reports a balance for
22
- * them. Once a detector sees the asset, it no longer needs to be tracked as
23
- * "custom" — the regular detection flow will keep it fresh.
21
+ * an upstream balance source (AccountsAPI / Websocket) reports a non-zero
22
+ * balance for them. Once a detector sees the asset with a real balance, it
23
+ * no longer needs to be tracked as "custom" — the regular detection flow
24
+ * will keep it fresh.
24
25
  *
25
26
  * Rules:
26
- * - Only the selected account's custom assets are considered.
27
+ * - Only the selected account's custom assets are considered. Switching the
28
+ * selected account triggers a fresh fetch, which re-runs graduation
29
+ * against the new account's balances.
27
30
  * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom
28
31
  * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left
29
32
  * alone.
33
+ * - Only positive balances graduate. A zero balance from AccountsAPI means
34
+ * the API knows about the token but the user does not currently hold it;
35
+ * keeping it in `customAssets` ensures RPC keeps polling so a future
36
+ * incoming transfer is reflected promptly.
30
37
  */
31
38
  export class CustomAssetGraduationMiddleware {
32
39
  constructor(options) {
@@ -70,6 +77,9 @@ export class CustomAssetGraduationMiddleware {
70
77
  if (!isEvmAssetId(rawAssetId)) {
71
78
  continue;
72
79
  }
80
+ if (!hasPositiveBalance(returnedBalances[rawAssetId])) {
81
+ continue;
82
+ }
73
83
  const normalizedAssetId = safeNormalize(rawAssetId);
74
84
  if (!customSet.has(normalizedAssetId)) {
75
85
  continue;
@@ -114,4 +124,22 @@ function safeNormalize(assetId) {
114
124
  return assetId;
115
125
  }
116
126
  }
127
+ /**
128
+ * Whether a balance entry reports a strictly positive amount. AccountsAPI
129
+ * may return zero for tokens it indexes but the user no longer holds; we
130
+ * treat those as non-graduating so RPC keeps polling and surfaces any
131
+ * future incoming transfer immediately.
132
+ *
133
+ * `AssetBalance.amount` is already a human-readable decimal string from
134
+ * both AccountsApi (e.g. "0.283549083429656057") and the websocket data
135
+ * source (which divides by `decimals` before emitting), so a `Number()`
136
+ * sign check is safe: `NaN`, `undefined`, empty strings, and zero all
137
+ * fail the comparison.
138
+ *
139
+ * @param balance - The balance entry from the response.
140
+ * @returns `true` when the balance amount represents a value greater than 0.
141
+ */
142
+ function hasPositiveBalance(balance) {
143
+ return Number(balance?.amount) > 0;
144
+ }
117
145
  //# sourceMappingURL=CustomAssetGraduationMiddleware.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"CustomAssetGraduationMiddleware.mjs","sourceRoot":"","sources":["../../src/middlewares/CustomAssetGraduationMiddleware.ts"],"names":[],"mappings":"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","sourcesContent":["import { KnownCaipNamespace } from '@metamask/utils';\n\nimport { projectLogger, createModuleLogger } from '../logger';\nimport { forDataTypes } from '../types';\nimport type { AccountId, Caip19AssetId, Middleware } from '../types';\nimport { normalizeAssetId } from '../utils';\n\nconst CONTROLLER_NAME = 'CustomAssetGraduationMiddleware';\n\nconst log = createModuleLogger(projectLogger, CONTROLLER_NAME);\n\nexport type CustomAssetGraduationMiddlewareOptions = {\n getSelectedAccountId: () => AccountId | undefined;\n removeCustomAsset: (accountId: AccountId, assetId: Caip19AssetId) => void;\n};\n\n/**\n * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when\n * an upstream balance source (AccountsAPI / Websocket) reports a balance for\n * them. Once a detector sees the asset, it no longer needs to be tracked as\n * \"custom\" — the regular detection flow will keep it fresh.\n *\n * Rules:\n * - Only the selected account's custom assets are considered.\n * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom\n * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left\n * alone.\n */\nexport class CustomAssetGraduationMiddleware {\n readonly name = CONTROLLER_NAME;\n\n readonly #getSelectedAccountId: () => AccountId | undefined;\n\n readonly #removeCustomAsset: (\n accountId: AccountId,\n assetId: Caip19AssetId,\n ) => void;\n\n constructor(options: CustomAssetGraduationMiddlewareOptions) {\n this.#getSelectedAccountId = options.getSelectedAccountId;\n this.#removeCustomAsset = options.removeCustomAsset;\n }\n\n getName(): string {\n return this.name;\n }\n\n get assetsMiddleware(): Middleware {\n return forDataTypes(['balance'], async (ctx, next) => {\n // Inspect the response BEFORE calling next() so we only consider\n // balances populated by upstream middleware (AccountsApi / Websocket /\n // Staked). This middleware is positioned in the pipeline before the\n // RPC fallback — RPC intentionally carries custom assets and must\n // never trigger graduation.\n const accountId = this.#getSelectedAccountId();\n if (!accountId) {\n return next(ctx);\n }\n\n const state = ctx.getAssetsState();\n const customForAccount = state.customAssets?.[accountId] ?? [];\n if (customForAccount.length === 0) {\n return next(ctx);\n }\n\n const returnedBalances = ctx.response.assetsBalance?.[accountId] ?? {};\n const returnedAssetIds = Object.keys(returnedBalances) as Caip19AssetId[];\n if (returnedAssetIds.length === 0) {\n return next(ctx);\n }\n\n // customAssets state is stored with checksummed/normalized asset IDs.\n // AccountsApiDataSource normalizes its response IDs, but\n // BackendWebsocketDataSource does not — so we normalize the response\n // side here to make the comparison robust to lower-case addresses\n // delivered over the websocket.\n const customSet = new Set(customForAccount);\n for (const rawAssetId of returnedAssetIds) {\n if (!isEvmAssetId(rawAssetId)) {\n continue;\n }\n const normalizedAssetId = safeNormalize(rawAssetId);\n if (!customSet.has(normalizedAssetId)) {\n continue;\n }\n log('Graduating custom asset', {\n accountId,\n assetId: normalizedAssetId,\n });\n this.#removeCustomAsset(accountId, normalizedAssetId);\n }\n\n return next(ctx);\n });\n }\n}\n\n/**\n * Check whether a CAIP-19 asset ID belongs to an EVM chain.\n *\n * @param assetId - The CAIP-19 asset ID to inspect.\n * @returns `true` when the asset's chain namespace is `eip155`.\n */\nfunction isEvmAssetId(assetId: Caip19AssetId): boolean {\n // CAIP-19 format: <namespace>:<chainRef>/<assetNamespace>:<assetRef>\n // The chain namespace is always the segment before the first colon.\n const namespace = assetId.split(':')[0];\n return namespace === KnownCaipNamespace.Eip155;\n}\n\n/**\n * Normalize a CAIP-19 asset ID, returning the original on failure. Some\n * malformed IDs (e.g. an asset reference that fails address checksumming)\n * make `normalizeAssetId` throw — in that case we fall back to the raw ID\n * so the graduation pass can still proceed for other assets.\n *\n * @param assetId - The CAIP-19 asset ID to normalize.\n * @returns The normalized ID, or the original on failure.\n */\nfunction safeNormalize(assetId: Caip19AssetId): Caip19AssetId {\n try {\n return normalizeAssetId(assetId);\n } catch {\n return assetId;\n }\n}\n"]}
1
+ {"version":3,"file":"CustomAssetGraduationMiddleware.mjs","sourceRoot":"","sources":["../../src/middlewares/CustomAssetGraduationMiddleware.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,OAAO,EAAE,kBAAkB,EAAE,wBAAwB;AAErD,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,sBAAkB;AAC9D,OAAO,EAAE,YAAY,EAAE,qBAAiB;AAOxC,OAAO,EAAE,gBAAgB,EAAE,2BAAiB;AAE5C,MAAM,eAAe,GAAG,iCAAiC,CAAC;AAE1D,MAAM,GAAG,GAAG,kBAAkB,CAAC,aAAa,EAAE,eAAe,CAAC,CAAC;AAO/D;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,OAAO,+BAA+B;IAU1C,YAAY,OAA+C;QATlD,SAAI,GAAG,eAAe,CAAC;QAEvB,wEAAmD;QAEnD,qEAGC;QAGR,uBAAA,IAAI,yDAAyB,OAAO,CAAC,oBAAoB,MAAA,CAAC;QAC1D,uBAAA,IAAI,sDAAsB,OAAO,CAAC,iBAAiB,MAAA,CAAC;IACtD,CAAC;IAED,OAAO;QACL,OAAO,IAAI,CAAC,IAAI,CAAC;IACnB,CAAC;IAED,IAAI,gBAAgB;QAClB,OAAO,YAAY,CAAC,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;YACnD,iEAAiE;YACjE,uEAAuE;YACvE,oEAAoE;YACpE,kEAAkE;YAClE,4BAA4B;YAC5B,MAAM,SAAS,GAAG,uBAAA,IAAI,6DAAsB,MAA1B,IAAI,CAAwB,CAAC;YAC/C,IAAI,CAAC,SAAS,EAAE,CAAC;gBACf,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;YACnB,CAAC;YAED,MAAM,KAAK,GAAG,GAAG,CAAC,cAAc,EAAE,CAAC;YACnC,MAAM,gBAAgB,GAAG,KAAK,CAAC,YAAY,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;YAC/D,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAClC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;YACnB,CAAC;YAED,MAAM,gBAAgB,GAAG,GAAG,CAAC,QAAQ,CAAC,aAAa,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;YACvE,MAAM,gBAAgB,GAAG,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAoB,CAAC;YAC1E,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAClC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;YACnB,CAAC;YAED,sEAAsE;YACtE,yDAAyD;YACzD,qEAAqE;YACrE,kEAAkE;YAClE,gCAAgC;YAChC,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,gBAAgB,CAAC,CAAC;YAC5C,KAAK,MAAM,UAAU,IAAI,gBAAgB,EAAE,CAAC;gBAC1C,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC,EAAE,CAAC;oBAC9B,SAAS;gBACX,CAAC;gBACD,IAAI,CAAC,kBAAkB,CAAC,gBAAgB,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC;oBACtD,SAAS;gBACX,CAAC;gBACD,MAAM,iBAAiB,GAAG,aAAa,CAAC,UAAU,CAAC,CAAC;gBACpD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,iBAAiB,CAAC,EAAE,CAAC;oBACtC,SAAS;gBACX,CAAC;gBACD,GAAG,CAAC,yBAAyB,EAAE;oBAC7B,SAAS;oBACT,OAAO,EAAE,iBAAiB;iBAC3B,CAAC,CAAC;gBACH,uBAAA,IAAI,0DAAmB,MAAvB,IAAI,EAAoB,SAAS,EAAE,iBAAiB,CAAC,CAAC;YACxD,CAAC;YAED,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;QACnB,CAAC,CAAC,CAAC;IACL,CAAC;CACF;;AAED;;;;;GAKG;AACH,SAAS,YAAY,CAAC,OAAsB;IAC1C,qEAAqE;IACrE,oEAAoE;IACpE,MAAM,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACxC,OAAO,SAAS,KAAK,kBAAkB,CAAC,MAAM,CAAC;AACjD,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,aAAa,CAAC,OAAsB;IAC3C,IAAI,CAAC;QACH,OAAO,gBAAgB,CAAC,OAAO,CAAC,CAAC;IACnC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,SAAS,kBAAkB,CAAC,OAAiC;IAC3D,OAAO,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;AACrC,CAAC","sourcesContent":["import { KnownCaipNamespace } from '@metamask/utils';\n\nimport { projectLogger, createModuleLogger } from '../logger';\nimport { forDataTypes } from '../types';\nimport type {\n AccountId,\n AssetBalance,\n Caip19AssetId,\n Middleware,\n} from '../types';\nimport { normalizeAssetId } from '../utils';\n\nconst CONTROLLER_NAME = 'CustomAssetGraduationMiddleware';\n\nconst log = createModuleLogger(projectLogger, CONTROLLER_NAME);\n\nexport type CustomAssetGraduationMiddlewareOptions = {\n getSelectedAccountId: () => AccountId | undefined;\n removeCustomAsset: (accountId: AccountId, assetId: Caip19AssetId) => void;\n};\n\n/**\n * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when\n * an upstream balance source (AccountsAPI / Websocket) reports a non-zero\n * balance for them. Once a detector sees the asset with a real balance, it\n * no longer needs to be tracked as \"custom\" — the regular detection flow\n * will keep it fresh.\n *\n * Rules:\n * - Only the selected account's custom assets are considered. Switching the\n * selected account triggers a fresh fetch, which re-runs graduation\n * against the new account's balances.\n * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom\n * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left\n * alone.\n * - Only positive balances graduate. A zero balance from AccountsAPI means\n * the API knows about the token but the user does not currently hold it;\n * keeping it in `customAssets` ensures RPC keeps polling so a future\n * incoming transfer is reflected promptly.\n */\nexport class CustomAssetGraduationMiddleware {\n readonly name = CONTROLLER_NAME;\n\n readonly #getSelectedAccountId: () => AccountId | undefined;\n\n readonly #removeCustomAsset: (\n accountId: AccountId,\n assetId: Caip19AssetId,\n ) => void;\n\n constructor(options: CustomAssetGraduationMiddlewareOptions) {\n this.#getSelectedAccountId = options.getSelectedAccountId;\n this.#removeCustomAsset = options.removeCustomAsset;\n }\n\n getName(): string {\n return this.name;\n }\n\n get assetsMiddleware(): Middleware {\n return forDataTypes(['balance'], async (ctx, next) => {\n // Inspect the response BEFORE calling next() so we only consider\n // balances populated by upstream middleware (AccountsApi / Websocket /\n // Staked). This middleware is positioned in the pipeline before the\n // RPC fallback — RPC intentionally carries custom assets and must\n // never trigger graduation.\n const accountId = this.#getSelectedAccountId();\n if (!accountId) {\n return next(ctx);\n }\n\n const state = ctx.getAssetsState();\n const customForAccount = state.customAssets?.[accountId] ?? [];\n if (customForAccount.length === 0) {\n return next(ctx);\n }\n\n const returnedBalances = ctx.response.assetsBalance?.[accountId] ?? {};\n const returnedAssetIds = Object.keys(returnedBalances) as Caip19AssetId[];\n if (returnedAssetIds.length === 0) {\n return next(ctx);\n }\n\n // customAssets state is stored with checksummed/normalized asset IDs.\n // AccountsApiDataSource normalizes its response IDs, but\n // BackendWebsocketDataSource does not — so we normalize the response\n // side here to make the comparison robust to lower-case addresses\n // delivered over the websocket.\n const customSet = new Set(customForAccount);\n for (const rawAssetId of returnedAssetIds) {\n if (!isEvmAssetId(rawAssetId)) {\n continue;\n }\n if (!hasPositiveBalance(returnedBalances[rawAssetId])) {\n continue;\n }\n const normalizedAssetId = safeNormalize(rawAssetId);\n if (!customSet.has(normalizedAssetId)) {\n continue;\n }\n log('Graduating custom asset', {\n accountId,\n assetId: normalizedAssetId,\n });\n this.#removeCustomAsset(accountId, normalizedAssetId);\n }\n\n return next(ctx);\n });\n }\n}\n\n/**\n * Check whether a CAIP-19 asset ID belongs to an EVM chain.\n *\n * @param assetId - The CAIP-19 asset ID to inspect.\n * @returns `true` when the asset's chain namespace is `eip155`.\n */\nfunction isEvmAssetId(assetId: Caip19AssetId): boolean {\n // CAIP-19 format: <namespace>:<chainRef>/<assetNamespace>:<assetRef>\n // The chain namespace is always the segment before the first colon.\n const namespace = assetId.split(':')[0];\n return namespace === KnownCaipNamespace.Eip155;\n}\n\n/**\n * Normalize a CAIP-19 asset ID, returning the original on failure. Some\n * malformed IDs (e.g. an asset reference that fails address checksumming)\n * make `normalizeAssetId` throw — in that case we fall back to the raw ID\n * so the graduation pass can still proceed for other assets.\n *\n * @param assetId - The CAIP-19 asset ID to normalize.\n * @returns The normalized ID, or the original on failure.\n */\nfunction safeNormalize(assetId: Caip19AssetId): Caip19AssetId {\n try {\n return normalizeAssetId(assetId);\n } catch {\n return assetId;\n }\n}\n\n/**\n * Whether a balance entry reports a strictly positive amount. AccountsAPI\n * may return zero for tokens it indexes but the user no longer holds; we\n * treat those as non-graduating so RPC keeps polling and surfaces any\n * future incoming transfer immediately.\n *\n * `AssetBalance.amount` is already a human-readable decimal string from\n * both AccountsApi (e.g. \"0.283549083429656057\") and the websocket data\n * source (which divides by `decimals` before emitting), so a `Number()`\n * sign check is safe: `NaN`, `undefined`, empty strings, and zero all\n * fail the comparison.\n *\n * @param balance - The balance entry from the response.\n * @returns `true` when the balance amount represents a value greater than 0.\n */\nfunction hasPositiveBalance(balance: AssetBalance | undefined): boolean {\n return Number(balance?.amount) > 0;\n}\n"]}
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pickRpcCustomAssetsSupplement = void 0;
4
+ const utils_1 = require("@metamask/utils");
5
+ /**
6
+ * Decide which chains require a supplemental `customAssetsOnly` RPC
7
+ * subscription so that user-imported customAssets are **always** polled by
8
+ * RPC — even when AccountsApi or the websocket data source has already
9
+ * claimed the chain in the regular handoff.
10
+ *
11
+ * RPC is the sole balance fetcher for customAssets:
12
+ * - AccountsApi indexes a curated token list and may return zero (or no
13
+ * entry at all) for a user-imported token, even on supported chains.
14
+ * - The websocket data source is push-only: it relays balance deltas from
15
+ * transactions, so a user who never transacts won't see balance updates.
16
+ *
17
+ * To guarantee freshness, we subscribe RPC in `customAssetsOnly` mode on
18
+ * any chain that:
19
+ * 1. has at least one selected account with a customAsset there;
20
+ * 2. is supported by the RPC data source;
21
+ * 3. is **not** already covered by the regular RPC subscription
22
+ * (`rpcAssignedChains`) — otherwise customAssets are picked up there;
23
+ * 4. is currently enabled (in `chainToAccounts`); polling a disabled
24
+ * chain wastes resources because the user can't see its balances.
25
+ *
26
+ * Malformed asset IDs are silently skipped — they can't be parsed into a
27
+ * chain anyway and shouldn't crash the subscription pipeline.
28
+ *
29
+ * @param input - Decision inputs.
30
+ * @returns Chains and account IDs that require supplemental subscription.
31
+ */
32
+ function pickRpcCustomAssetsSupplement(input) {
33
+ const { accountIds, customAssetsByAccount, rpcAssignedChains, rpcAvailableChains, enabledChains, } = input;
34
+ const chains = new Set();
35
+ const supplementalAccountIds = new Set();
36
+ for (const accountId of accountIds) {
37
+ const customForAccount = customAssetsByAccount[accountId];
38
+ if (!customForAccount || customForAccount.length === 0) {
39
+ continue;
40
+ }
41
+ // Mirror the controller's prior behavior: an account with any customAsset
42
+ // (even if all are filtered out below) is added to the candidate set.
43
+ // The empty-chains early-return in the caller still prevents a useless
44
+ // subscription, so this preserves observable behavior.
45
+ supplementalAccountIds.add(accountId);
46
+ for (const assetId of customForAccount) {
47
+ let chainId;
48
+ try {
49
+ chainId = (0, utils_1.parseCaipAssetType)(assetId).chainId;
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ if (rpcAssignedChains.has(chainId)) {
55
+ continue;
56
+ }
57
+ if (!rpcAvailableChains.has(chainId)) {
58
+ continue;
59
+ }
60
+ if (!enabledChains.has(chainId)) {
61
+ continue;
62
+ }
63
+ chains.add(chainId);
64
+ }
65
+ }
66
+ return {
67
+ chains: [...chains],
68
+ accountIds: supplementalAccountIds,
69
+ };
70
+ }
71
+ exports.pickRpcCustomAssetsSupplement = pickRpcCustomAssetsSupplement;
72
+ //# sourceMappingURL=customAssetsRpcSupplement.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customAssetsRpcSupplement.cjs","sourceRoot":"","sources":["../../src/utils/customAssetsRpcSupplement.ts"],"names":[],"mappings":";;;AAAA,2CAAqD;AAoCrD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,SAAgB,6BAA6B,CAC3C,KAAqC;IAErC,MAAM,EACJ,UAAU,EACV,qBAAqB,EACrB,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,GACd,GAAG,KAAK,CAAC;IAEV,MAAM,MAAM,GAAG,IAAI,GAAG,EAAW,CAAC;IAClC,MAAM,sBAAsB,GAAG,IAAI,GAAG,EAAU,CAAC;IAEjD,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,MAAM,gBAAgB,GAAG,qBAAqB,CAAC,SAAS,CAAC,CAAC;QAC1D,IAAI,CAAC,gBAAgB,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvD,SAAS;QACX,CAAC;QACD,0EAA0E;QAC1E,sEAAsE;QACtE,uEAAuE;QACvE,uDAAuD;QACvD,sBAAsB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACtC,KAAK,MAAM,OAAO,IAAI,gBAAgB,EAAE,CAAC;YACvC,IAAI,OAAgB,CAAC;YACrB,IAAI,CAAC;gBACH,OAAO,GAAG,IAAA,0BAAkB,EAAC,OAAO,CAAC,CAAC,OAAO,CAAC;YAChD,CAAC;YAAC,MAAM,CAAC;gBACP,SAAS;YACX,CAAC;YACD,IAAI,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gBACnC,SAAS;YACX,CAAC;YACD,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gBACrC,SAAS;YACX,CAAC;YACD,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gBAChC,SAAS;YACX,CAAC;YACD,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACtB,CAAC;IACH,CAAC;IAED,OAAO;QACL,MAAM,EAAE,CAAC,GAAG,MAAM,CAAC;QACnB,UAAU,EAAE,sBAAsB;KACnC,CAAC;AACJ,CAAC;AAhDD,sEAgDC","sourcesContent":["import { parseCaipAssetType } from '@metamask/utils';\n\nimport type { Caip19AssetId, ChainId } from '../types';\n\n/**\n * Inputs needed to decide whether `RpcDataSource` should run a supplemental\n * `customAssetsOnly` subscription. All inputs are pre-computed by the caller\n * (controller) so this helper is fully pure and deterministic.\n */\nexport type RpcCustomAssetsSupplementInput = {\n /** Selected account IDs being considered for subscription. */\n accountIds: string[];\n /** `state.customAssets` slice — accountId → CAIP-19 asset IDs. */\n customAssetsByAccount: Record<string, Caip19AssetId[]>;\n /** Chains the regular RPC subscription already covers. */\n rpcAssignedChains: Set<ChainId>;\n /** Chains the RPC data source can serve (its current activeChains). */\n rpcAvailableChains: Set<ChainId>;\n /**\n * Chains that are currently enabled for at least one selected account\n * (i.e. keys of the controller's chainToAccounts map).\n */\n enabledChains: Set<ChainId>;\n};\n\n/**\n * The decision output: which chains and accounts need a supplemental\n * `customAssetsOnly` RPC subscription.\n */\nexport type RpcCustomAssetsSupplementResult = {\n /** Chains that need a supplemental customAssetsOnly RPC subscription. */\n chains: ChainId[];\n /** Accounts that own at least one customAsset on a supplemental chain. */\n accountIds: Set<string>;\n};\n\n/**\n * Decide which chains require a supplemental `customAssetsOnly` RPC\n * subscription so that user-imported customAssets are **always** polled by\n * RPC — even when AccountsApi or the websocket data source has already\n * claimed the chain in the regular handoff.\n *\n * RPC is the sole balance fetcher for customAssets:\n * - AccountsApi indexes a curated token list and may return zero (or no\n * entry at all) for a user-imported token, even on supported chains.\n * - The websocket data source is push-only: it relays balance deltas from\n * transactions, so a user who never transacts won't see balance updates.\n *\n * To guarantee freshness, we subscribe RPC in `customAssetsOnly` mode on\n * any chain that:\n * 1. has at least one selected account with a customAsset there;\n * 2. is supported by the RPC data source;\n * 3. is **not** already covered by the regular RPC subscription\n * (`rpcAssignedChains`) — otherwise customAssets are picked up there;\n * 4. is currently enabled (in `chainToAccounts`); polling a disabled\n * chain wastes resources because the user can't see its balances.\n *\n * Malformed asset IDs are silently skipped — they can't be parsed into a\n * chain anyway and shouldn't crash the subscription pipeline.\n *\n * @param input - Decision inputs.\n * @returns Chains and account IDs that require supplemental subscription.\n */\nexport function pickRpcCustomAssetsSupplement(\n input: RpcCustomAssetsSupplementInput,\n): RpcCustomAssetsSupplementResult {\n const {\n accountIds,\n customAssetsByAccount,\n rpcAssignedChains,\n rpcAvailableChains,\n enabledChains,\n } = input;\n\n const chains = new Set<ChainId>();\n const supplementalAccountIds = new Set<string>();\n\n for (const accountId of accountIds) {\n const customForAccount = customAssetsByAccount[accountId];\n if (!customForAccount || customForAccount.length === 0) {\n continue;\n }\n // Mirror the controller's prior behavior: an account with any customAsset\n // (even if all are filtered out below) is added to the candidate set.\n // The empty-chains early-return in the caller still prevents a useless\n // subscription, so this preserves observable behavior.\n supplementalAccountIds.add(accountId);\n for (const assetId of customForAccount) {\n let chainId: ChainId;\n try {\n chainId = parseCaipAssetType(assetId).chainId;\n } catch {\n continue;\n }\n if (rpcAssignedChains.has(chainId)) {\n continue;\n }\n if (!rpcAvailableChains.has(chainId)) {\n continue;\n }\n if (!enabledChains.has(chainId)) {\n continue;\n }\n chains.add(chainId);\n }\n }\n\n return {\n chains: [...chains],\n accountIds: supplementalAccountIds,\n };\n}\n"]}
@@ -0,0 +1,60 @@
1
+ import type { Caip19AssetId, ChainId } from "../types.cjs";
2
+ /**
3
+ * Inputs needed to decide whether `RpcDataSource` should run a supplemental
4
+ * `customAssetsOnly` subscription. All inputs are pre-computed by the caller
5
+ * (controller) so this helper is fully pure and deterministic.
6
+ */
7
+ export type RpcCustomAssetsSupplementInput = {
8
+ /** Selected account IDs being considered for subscription. */
9
+ accountIds: string[];
10
+ /** `state.customAssets` slice — accountId → CAIP-19 asset IDs. */
11
+ customAssetsByAccount: Record<string, Caip19AssetId[]>;
12
+ /** Chains the regular RPC subscription already covers. */
13
+ rpcAssignedChains: Set<ChainId>;
14
+ /** Chains the RPC data source can serve (its current activeChains). */
15
+ rpcAvailableChains: Set<ChainId>;
16
+ /**
17
+ * Chains that are currently enabled for at least one selected account
18
+ * (i.e. keys of the controller's chainToAccounts map).
19
+ */
20
+ enabledChains: Set<ChainId>;
21
+ };
22
+ /**
23
+ * The decision output: which chains and accounts need a supplemental
24
+ * `customAssetsOnly` RPC subscription.
25
+ */
26
+ export type RpcCustomAssetsSupplementResult = {
27
+ /** Chains that need a supplemental customAssetsOnly RPC subscription. */
28
+ chains: ChainId[];
29
+ /** Accounts that own at least one customAsset on a supplemental chain. */
30
+ accountIds: Set<string>;
31
+ };
32
+ /**
33
+ * Decide which chains require a supplemental `customAssetsOnly` RPC
34
+ * subscription so that user-imported customAssets are **always** polled by
35
+ * RPC — even when AccountsApi or the websocket data source has already
36
+ * claimed the chain in the regular handoff.
37
+ *
38
+ * RPC is the sole balance fetcher for customAssets:
39
+ * - AccountsApi indexes a curated token list and may return zero (or no
40
+ * entry at all) for a user-imported token, even on supported chains.
41
+ * - The websocket data source is push-only: it relays balance deltas from
42
+ * transactions, so a user who never transacts won't see balance updates.
43
+ *
44
+ * To guarantee freshness, we subscribe RPC in `customAssetsOnly` mode on
45
+ * any chain that:
46
+ * 1. has at least one selected account with a customAsset there;
47
+ * 2. is supported by the RPC data source;
48
+ * 3. is **not** already covered by the regular RPC subscription
49
+ * (`rpcAssignedChains`) — otherwise customAssets are picked up there;
50
+ * 4. is currently enabled (in `chainToAccounts`); polling a disabled
51
+ * chain wastes resources because the user can't see its balances.
52
+ *
53
+ * Malformed asset IDs are silently skipped — they can't be parsed into a
54
+ * chain anyway and shouldn't crash the subscription pipeline.
55
+ *
56
+ * @param input - Decision inputs.
57
+ * @returns Chains and account IDs that require supplemental subscription.
58
+ */
59
+ export declare function pickRpcCustomAssetsSupplement(input: RpcCustomAssetsSupplementInput): RpcCustomAssetsSupplementResult;
60
+ //# sourceMappingURL=customAssetsRpcSupplement.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customAssetsRpcSupplement.d.cts","sourceRoot":"","sources":["../../src/utils/customAssetsRpcSupplement.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAiB;AAEvD;;;;GAIG;AACH,MAAM,MAAM,8BAA8B,GAAG;IAC3C,8DAA8D;IAC9D,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,kEAAkE;IAClE,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;IACvD,0DAA0D;IAC1D,iBAAiB,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;IAChC,uEAAuE;IACvE,kBAAkB,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;IACjC;;;OAGG;IACH,aAAa,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;CAC7B,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,+BAA+B,GAAG;IAC5C,yEAAyE;IACzE,MAAM,EAAE,OAAO,EAAE,CAAC;IAClB,0EAA0E;IAC1E,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,8BAA8B,GACpC,+BAA+B,CA8CjC"}
@@ -0,0 +1,60 @@
1
+ import type { Caip19AssetId, ChainId } from "../types.mjs";
2
+ /**
3
+ * Inputs needed to decide whether `RpcDataSource` should run a supplemental
4
+ * `customAssetsOnly` subscription. All inputs are pre-computed by the caller
5
+ * (controller) so this helper is fully pure and deterministic.
6
+ */
7
+ export type RpcCustomAssetsSupplementInput = {
8
+ /** Selected account IDs being considered for subscription. */
9
+ accountIds: string[];
10
+ /** `state.customAssets` slice — accountId → CAIP-19 asset IDs. */
11
+ customAssetsByAccount: Record<string, Caip19AssetId[]>;
12
+ /** Chains the regular RPC subscription already covers. */
13
+ rpcAssignedChains: Set<ChainId>;
14
+ /** Chains the RPC data source can serve (its current activeChains). */
15
+ rpcAvailableChains: Set<ChainId>;
16
+ /**
17
+ * Chains that are currently enabled for at least one selected account
18
+ * (i.e. keys of the controller's chainToAccounts map).
19
+ */
20
+ enabledChains: Set<ChainId>;
21
+ };
22
+ /**
23
+ * The decision output: which chains and accounts need a supplemental
24
+ * `customAssetsOnly` RPC subscription.
25
+ */
26
+ export type RpcCustomAssetsSupplementResult = {
27
+ /** Chains that need a supplemental customAssetsOnly RPC subscription. */
28
+ chains: ChainId[];
29
+ /** Accounts that own at least one customAsset on a supplemental chain. */
30
+ accountIds: Set<string>;
31
+ };
32
+ /**
33
+ * Decide which chains require a supplemental `customAssetsOnly` RPC
34
+ * subscription so that user-imported customAssets are **always** polled by
35
+ * RPC — even when AccountsApi or the websocket data source has already
36
+ * claimed the chain in the regular handoff.
37
+ *
38
+ * RPC is the sole balance fetcher for customAssets:
39
+ * - AccountsApi indexes a curated token list and may return zero (or no
40
+ * entry at all) for a user-imported token, even on supported chains.
41
+ * - The websocket data source is push-only: it relays balance deltas from
42
+ * transactions, so a user who never transacts won't see balance updates.
43
+ *
44
+ * To guarantee freshness, we subscribe RPC in `customAssetsOnly` mode on
45
+ * any chain that:
46
+ * 1. has at least one selected account with a customAsset there;
47
+ * 2. is supported by the RPC data source;
48
+ * 3. is **not** already covered by the regular RPC subscription
49
+ * (`rpcAssignedChains`) — otherwise customAssets are picked up there;
50
+ * 4. is currently enabled (in `chainToAccounts`); polling a disabled
51
+ * chain wastes resources because the user can't see its balances.
52
+ *
53
+ * Malformed asset IDs are silently skipped — they can't be parsed into a
54
+ * chain anyway and shouldn't crash the subscription pipeline.
55
+ *
56
+ * @param input - Decision inputs.
57
+ * @returns Chains and account IDs that require supplemental subscription.
58
+ */
59
+ export declare function pickRpcCustomAssetsSupplement(input: RpcCustomAssetsSupplementInput): RpcCustomAssetsSupplementResult;
60
+ //# sourceMappingURL=customAssetsRpcSupplement.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customAssetsRpcSupplement.d.mts","sourceRoot":"","sources":["../../src/utils/customAssetsRpcSupplement.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAiB;AAEvD;;;;GAIG;AACH,MAAM,MAAM,8BAA8B,GAAG;IAC3C,8DAA8D;IAC9D,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,kEAAkE;IAClE,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;IACvD,0DAA0D;IAC1D,iBAAiB,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;IAChC,uEAAuE;IACvE,kBAAkB,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;IACjC;;;OAGG;IACH,aAAa,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;CAC7B,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,+BAA+B,GAAG;IAC5C,yEAAyE;IACzE,MAAM,EAAE,OAAO,EAAE,CAAC;IAClB,0EAA0E;IAC1E,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,8BAA8B,GACpC,+BAA+B,CA8CjC"}
@@ -0,0 +1,68 @@
1
+ import { parseCaipAssetType } from "@metamask/utils";
2
+ /**
3
+ * Decide which chains require a supplemental `customAssetsOnly` RPC
4
+ * subscription so that user-imported customAssets are **always** polled by
5
+ * RPC — even when AccountsApi or the websocket data source has already
6
+ * claimed the chain in the regular handoff.
7
+ *
8
+ * RPC is the sole balance fetcher for customAssets:
9
+ * - AccountsApi indexes a curated token list and may return zero (or no
10
+ * entry at all) for a user-imported token, even on supported chains.
11
+ * - The websocket data source is push-only: it relays balance deltas from
12
+ * transactions, so a user who never transacts won't see balance updates.
13
+ *
14
+ * To guarantee freshness, we subscribe RPC in `customAssetsOnly` mode on
15
+ * any chain that:
16
+ * 1. has at least one selected account with a customAsset there;
17
+ * 2. is supported by the RPC data source;
18
+ * 3. is **not** already covered by the regular RPC subscription
19
+ * (`rpcAssignedChains`) — otherwise customAssets are picked up there;
20
+ * 4. is currently enabled (in `chainToAccounts`); polling a disabled
21
+ * chain wastes resources because the user can't see its balances.
22
+ *
23
+ * Malformed asset IDs are silently skipped — they can't be parsed into a
24
+ * chain anyway and shouldn't crash the subscription pipeline.
25
+ *
26
+ * @param input - Decision inputs.
27
+ * @returns Chains and account IDs that require supplemental subscription.
28
+ */
29
+ export function pickRpcCustomAssetsSupplement(input) {
30
+ const { accountIds, customAssetsByAccount, rpcAssignedChains, rpcAvailableChains, enabledChains, } = input;
31
+ const chains = new Set();
32
+ const supplementalAccountIds = new Set();
33
+ for (const accountId of accountIds) {
34
+ const customForAccount = customAssetsByAccount[accountId];
35
+ if (!customForAccount || customForAccount.length === 0) {
36
+ continue;
37
+ }
38
+ // Mirror the controller's prior behavior: an account with any customAsset
39
+ // (even if all are filtered out below) is added to the candidate set.
40
+ // The empty-chains early-return in the caller still prevents a useless
41
+ // subscription, so this preserves observable behavior.
42
+ supplementalAccountIds.add(accountId);
43
+ for (const assetId of customForAccount) {
44
+ let chainId;
45
+ try {
46
+ chainId = parseCaipAssetType(assetId).chainId;
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ if (rpcAssignedChains.has(chainId)) {
52
+ continue;
53
+ }
54
+ if (!rpcAvailableChains.has(chainId)) {
55
+ continue;
56
+ }
57
+ if (!enabledChains.has(chainId)) {
58
+ continue;
59
+ }
60
+ chains.add(chainId);
61
+ }
62
+ }
63
+ return {
64
+ chains: [...chains],
65
+ accountIds: supplementalAccountIds,
66
+ };
67
+ }
68
+ //# sourceMappingURL=customAssetsRpcSupplement.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customAssetsRpcSupplement.mjs","sourceRoot":"","sources":["../../src/utils/customAssetsRpcSupplement.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,wBAAwB;AAoCrD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,UAAU,6BAA6B,CAC3C,KAAqC;IAErC,MAAM,EACJ,UAAU,EACV,qBAAqB,EACrB,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,GACd,GAAG,KAAK,CAAC;IAEV,MAAM,MAAM,GAAG,IAAI,GAAG,EAAW,CAAC;IAClC,MAAM,sBAAsB,GAAG,IAAI,GAAG,EAAU,CAAC;IAEjD,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,MAAM,gBAAgB,GAAG,qBAAqB,CAAC,SAAS,CAAC,CAAC;QAC1D,IAAI,CAAC,gBAAgB,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvD,SAAS;QACX,CAAC;QACD,0EAA0E;QAC1E,sEAAsE;QACtE,uEAAuE;QACvE,uDAAuD;QACvD,sBAAsB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACtC,KAAK,MAAM,OAAO,IAAI,gBAAgB,EAAE,CAAC;YACvC,IAAI,OAAgB,CAAC;YACrB,IAAI,CAAC;gBACH,OAAO,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC;YAChD,CAAC;YAAC,MAAM,CAAC;gBACP,SAAS;YACX,CAAC;YACD,IAAI,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gBACnC,SAAS;YACX,CAAC;YACD,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gBACrC,SAAS;YACX,CAAC;YACD,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gBAChC,SAAS;YACX,CAAC;YACD,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACtB,CAAC;IACH,CAAC;IAED,OAAO;QACL,MAAM,EAAE,CAAC,GAAG,MAAM,CAAC;QACnB,UAAU,EAAE,sBAAsB;KACnC,CAAC;AACJ,CAAC","sourcesContent":["import { parseCaipAssetType } from '@metamask/utils';\n\nimport type { Caip19AssetId, ChainId } from '../types';\n\n/**\n * Inputs needed to decide whether `RpcDataSource` should run a supplemental\n * `customAssetsOnly` subscription. All inputs are pre-computed by the caller\n * (controller) so this helper is fully pure and deterministic.\n */\nexport type RpcCustomAssetsSupplementInput = {\n /** Selected account IDs being considered for subscription. */\n accountIds: string[];\n /** `state.customAssets` slice — accountId → CAIP-19 asset IDs. */\n customAssetsByAccount: Record<string, Caip19AssetId[]>;\n /** Chains the regular RPC subscription already covers. */\n rpcAssignedChains: Set<ChainId>;\n /** Chains the RPC data source can serve (its current activeChains). */\n rpcAvailableChains: Set<ChainId>;\n /**\n * Chains that are currently enabled for at least one selected account\n * (i.e. keys of the controller's chainToAccounts map).\n */\n enabledChains: Set<ChainId>;\n};\n\n/**\n * The decision output: which chains and accounts need a supplemental\n * `customAssetsOnly` RPC subscription.\n */\nexport type RpcCustomAssetsSupplementResult = {\n /** Chains that need a supplemental customAssetsOnly RPC subscription. */\n chains: ChainId[];\n /** Accounts that own at least one customAsset on a supplemental chain. */\n accountIds: Set<string>;\n};\n\n/**\n * Decide which chains require a supplemental `customAssetsOnly` RPC\n * subscription so that user-imported customAssets are **always** polled by\n * RPC — even when AccountsApi or the websocket data source has already\n * claimed the chain in the regular handoff.\n *\n * RPC is the sole balance fetcher for customAssets:\n * - AccountsApi indexes a curated token list and may return zero (or no\n * entry at all) for a user-imported token, even on supported chains.\n * - The websocket data source is push-only: it relays balance deltas from\n * transactions, so a user who never transacts won't see balance updates.\n *\n * To guarantee freshness, we subscribe RPC in `customAssetsOnly` mode on\n * any chain that:\n * 1. has at least one selected account with a customAsset there;\n * 2. is supported by the RPC data source;\n * 3. is **not** already covered by the regular RPC subscription\n * (`rpcAssignedChains`) — otherwise customAssets are picked up there;\n * 4. is currently enabled (in `chainToAccounts`); polling a disabled\n * chain wastes resources because the user can't see its balances.\n *\n * Malformed asset IDs are silently skipped — they can't be parsed into a\n * chain anyway and shouldn't crash the subscription pipeline.\n *\n * @param input - Decision inputs.\n * @returns Chains and account IDs that require supplemental subscription.\n */\nexport function pickRpcCustomAssetsSupplement(\n input: RpcCustomAssetsSupplementInput,\n): RpcCustomAssetsSupplementResult {\n const {\n accountIds,\n customAssetsByAccount,\n rpcAssignedChains,\n rpcAvailableChains,\n enabledChains,\n } = input;\n\n const chains = new Set<ChainId>();\n const supplementalAccountIds = new Set<string>();\n\n for (const accountId of accountIds) {\n const customForAccount = customAssetsByAccount[accountId];\n if (!customForAccount || customForAccount.length === 0) {\n continue;\n }\n // Mirror the controller's prior behavior: an account with any customAsset\n // (even if all are filtered out below) is added to the candidate set.\n // The empty-chains early-return in the caller still prevents a useless\n // subscription, so this preserves observable behavior.\n supplementalAccountIds.add(accountId);\n for (const assetId of customForAccount) {\n let chainId: ChainId;\n try {\n chainId = parseCaipAssetType(assetId).chainId;\n } catch {\n continue;\n }\n if (rpcAssignedChains.has(chainId)) {\n continue;\n }\n if (!rpcAvailableChains.has(chainId)) {\n continue;\n }\n if (!enabledChains.has(chainId)) {\n continue;\n }\n chains.add(chainId);\n }\n }\n\n return {\n chains: [...chains],\n accountIds: supplementalAccountIds,\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamask/assets-controller",
3
- "version": "6.4.0",
3
+ "version": "7.0.1",
4
4
  "description": "Tracks assets balances/prices and handles token detection across all digital assets",
5
5
  "keywords": [
6
6
  "Ethereum",
@@ -56,27 +56,27 @@
56
56
  "@ethereumjs/util": "^9.1.0",
57
57
  "@ethersproject/abi": "^5.7.0",
58
58
  "@ethersproject/providers": "^5.7.0",
59
- "@metamask/account-tree-controller": "^7.2.0",
60
- "@metamask/accounts-controller": "^38.0.0",
61
- "@metamask/assets-controllers": "^106.0.0",
59
+ "@metamask/account-tree-controller": "^7.3.0",
60
+ "@metamask/accounts-controller": "^38.1.0",
61
+ "@metamask/assets-controllers": "^106.0.1",
62
62
  "@metamask/base-controller": "^9.1.0",
63
63
  "@metamask/client-controller": "^1.0.1",
64
- "@metamask/controller-utils": "^11.20.0",
65
- "@metamask/core-backend": "^6.2.1",
64
+ "@metamask/controller-utils": "^12.0.0",
65
+ "@metamask/core-backend": "^6.2.2",
66
66
  "@metamask/keyring-api": "^23.1.0",
67
- "@metamask/keyring-controller": "^25.4.0",
67
+ "@metamask/keyring-controller": "^25.5.0",
68
68
  "@metamask/keyring-internal-api": "^11.0.1",
69
69
  "@metamask/keyring-snap-client": "^9.0.2",
70
70
  "@metamask/messenger": "^1.2.0",
71
- "@metamask/network-controller": "^30.1.0",
72
- "@metamask/network-enablement-controller": "^5.1.0",
73
- "@metamask/permission-controller": "^13.0.0",
74
- "@metamask/phishing-controller": "^17.1.1",
75
- "@metamask/polling-controller": "^16.0.4",
71
+ "@metamask/network-controller": "^31.0.0",
72
+ "@metamask/network-enablement-controller": "^5.1.1",
73
+ "@metamask/permission-controller": "^13.1.1",
74
+ "@metamask/phishing-controller": "^17.1.2",
75
+ "@metamask/polling-controller": "^16.0.5",
76
76
  "@metamask/preferences-controller": "^23.1.0",
77
77
  "@metamask/snaps-controllers": "^19.0.0",
78
78
  "@metamask/snaps-utils": "^12.1.2",
79
- "@metamask/transaction-controller": "^65.1.0",
79
+ "@metamask/transaction-controller": "^65.3.0",
80
80
  "@metamask/utils": "^11.9.0",
81
81
  "async-mutex": "^0.5.0",
82
82
  "bignumber.js": "^9.1.2",