Skip to content

Commit 2ccbbe5

Browse files
Kriys94cursoragent
andcommitted
feat(assets-controller): add getAsset
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 61996f5 commit 2ccbbe5

5 files changed

Lines changed: 225 additions & 1 deletion

File tree

packages/assets-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `getAsset(accountId, assetId)` method and `AssetsController:getAsset` messenger action that returns the combined `Asset` (balance, metadata, price) for a single account/asset pair from controller state, or `undefined` when a complete renderable asset is not available ([#9521](https://github.com/MetaMask/core/pull/9521))
13+
1014
### Changed
1115

1216
- Bump `@metamask/network-enablement-controller` from `^5.5.0` to `^5.6.0` ([#9520](https://github.com/MetaMask/core/pull/9520))

packages/assets-controller/src/AssetsController-method-action-types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,29 @@ export type AssetsControllerGetAssetMetadataAction = {
2020
handler: AssetsController['getAssetMetadata'];
2121
};
2222

23+
/**
24+
* Get a single combined asset (balance + metadata + price + computed
25+
* `fiatValue`) for an account directly from controller state.
26+
*
27+
* Reuses the same state-composition and filtering logic as `getAssets`
28+
* (balance and metadata are required, a missing price falls back to
29+
* `{ price: 0, lastUpdated: 0 }` with `fiatValue: 0`, and hidden or
30+
* otherwise filtered assets are excluded) so the returned shape never
31+
* drifts from `getAssets`. Reads from current state only and does not
32+
* trigger a data-source refresh.
33+
*
34+
* @param accountId - The account ID (`InternalAccount.id`, not an address).
35+
* @param assetId - The CAIP-19 asset ID including chain scope
36+
* (e.g. `eip155:1/erc20:0x...`).
37+
* @returns The combined `Asset`, or `undefined` when no complete
38+
* renderable asset (balance + metadata) exists for the account/asset pair.
39+
* @throws If `accountId` is empty or `assetId` is not a valid CAIP-19 asset ID.
40+
*/
41+
export type AssetsControllerGetAssetAction = {
42+
type: `AssetsController:getAsset`;
43+
handler: AssetsController['getAsset'];
44+
};
45+
2346
export type AssetsControllerGetAssetsPriceAction = {
2447
type: `AssetsController:getAssetsPrice`;
2548
handler: AssetsController['getAssetsPrice'];
@@ -132,6 +155,7 @@ export type AssetsControllerMethodActions =
132155
| AssetsControllerGetAssetsAction
133156
| AssetsControllerGetAssetsBalanceAction
134157
| AssetsControllerGetAssetMetadataAction
158+
| AssetsControllerGetAssetAction
135159
| AssetsControllerGetAssetsPriceAction
136160
| AssetsControllerGetExchangeRatesForBridgeAction
137161
| AssetsControllerGetStateForTransactionPayAction

packages/assets-controller/src/AssetsController.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,154 @@ describe('AssetsController', () => {
856856
});
857857
});
858858

859+
describe('getAsset', () => {
860+
const metadata = {
861+
type: 'erc20' as const,
862+
symbol: 'USDC',
863+
name: 'USD Coin',
864+
decimals: 6,
865+
};
866+
867+
it('returns the combined asset with computed fiatValue', async () => {
868+
const initialState: Partial<AssetsControllerState> = {
869+
assetsInfo: { [MOCK_ASSET_ID]: metadata },
870+
assetsBalance: {
871+
[MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } },
872+
},
873+
assetsPrice: { [MOCK_ASSET_ID]: { price: 2, lastUpdated: 123 } },
874+
};
875+
876+
await withController({ state: initialState }, ({ controller }) => {
877+
const asset = controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID);
878+
879+
expect(asset).toStrictEqual({
880+
id: MOCK_ASSET_ID,
881+
chainId: 'eip155:1',
882+
balance: { amount: '100' },
883+
metadata,
884+
price: { price: 2, lastUpdated: 123 },
885+
fiatValue: 200,
886+
});
887+
});
888+
});
889+
890+
it('normalizes a lowercase EVM asset ID before lookup', async () => {
891+
const initialState: Partial<AssetsControllerState> = {
892+
assetsInfo: { [MOCK_ASSET_ID]: metadata },
893+
assetsBalance: {
894+
[MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '1' } },
895+
},
896+
assetsPrice: { [MOCK_ASSET_ID]: { price: 1, lastUpdated: 1 } },
897+
};
898+
899+
await withController({ state: initialState }, ({ controller }) => {
900+
const asset = controller.getAsset(
901+
MOCK_ACCOUNT_ID,
902+
MOCK_ASSET_ID_LOWERCASE,
903+
);
904+
905+
expect(asset?.id).toBe(MOCK_ASSET_ID);
906+
});
907+
});
908+
909+
it('returns undefined when the balance is missing', async () => {
910+
const initialState: Partial<AssetsControllerState> = {
911+
assetsInfo: { [MOCK_ASSET_ID]: metadata },
912+
};
913+
914+
await withController({ state: initialState }, ({ controller }) => {
915+
expect(
916+
controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID),
917+
).toBeUndefined();
918+
});
919+
});
920+
921+
it('returns undefined when the metadata is missing', async () => {
922+
const initialState: Partial<AssetsControllerState> = {
923+
assetsBalance: {
924+
[MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } },
925+
},
926+
};
927+
928+
await withController({ state: initialState }, ({ controller }) => {
929+
expect(
930+
controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID),
931+
).toBeUndefined();
932+
});
933+
});
934+
935+
it('falls back to a zero price and fiatValue when the price is missing', async () => {
936+
const initialState: Partial<AssetsControllerState> = {
937+
assetsInfo: { [MOCK_ASSET_ID]: metadata },
938+
assetsBalance: {
939+
[MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } },
940+
},
941+
};
942+
943+
await withController({ state: initialState }, ({ controller }) => {
944+
const asset = controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID);
945+
946+
expect(asset?.price).toStrictEqual({ price: 0, lastUpdated: 0 });
947+
expect(asset?.fiatValue).toBe(0);
948+
});
949+
});
950+
951+
it('returns undefined for a hidden asset', async () => {
952+
const initialState: Partial<AssetsControllerState> = {
953+
assetsInfo: { [MOCK_ASSET_ID]: metadata },
954+
assetsBalance: {
955+
[MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } },
956+
},
957+
assetPreferences: { [MOCK_ASSET_ID]: { hidden: true } },
958+
};
959+
960+
await withController({ state: initialState }, ({ controller }) => {
961+
expect(
962+
controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID),
963+
).toBeUndefined();
964+
});
965+
});
966+
967+
it('throws when accountId is empty', async () => {
968+
await withController(({ controller }) => {
969+
expect(() =>
970+
controller.getAsset('' as AccountId, MOCK_ASSET_ID),
971+
).toThrow('accountId must be a non-empty string');
972+
});
973+
});
974+
975+
it('throws when assetId is not a valid CAIP-19 asset ID', async () => {
976+
await withController(({ controller }) => {
977+
expect(() =>
978+
controller.getAsset(
979+
MOCK_ACCOUNT_ID,
980+
'not-a-caip-19' as Caip19AssetId,
981+
),
982+
).toThrow('invalid CAIP-19 assetId');
983+
});
984+
});
985+
986+
it('is exposed as the AssetsController:getAsset messenger action', async () => {
987+
const initialState: Partial<AssetsControllerState> = {
988+
assetsInfo: { [MOCK_ASSET_ID]: metadata },
989+
assetsBalance: {
990+
[MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } },
991+
},
992+
assetsPrice: { [MOCK_ASSET_ID]: { price: 2, lastUpdated: 123 } },
993+
};
994+
995+
await withController({ state: initialState }, ({ messenger }) => {
996+
const asset = messenger.call(
997+
'AssetsController:getAsset',
998+
MOCK_ACCOUNT_ID,
999+
MOCK_ASSET_ID,
1000+
);
1001+
1002+
expect(asset?.fiatValue).toBe(200);
1003+
});
1004+
});
1005+
});
1006+
8591007
describe('getAssets', () => {
8601008
it('returns empty object when no balances exist', async () => {
8611009
await withController(async ({ controller }) => {

packages/assets-controller/src/AssetsController.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ const MESSENGER_EXPOSED_METHODS = [
181181
'getAssets',
182182
'getAssetsBalance',
183183
'getAssetMetadata',
184+
'getAsset',
184185
'getAssetsPrice',
185186
'getExchangeRatesForBridge',
186187
'getStateForTransactionPay',
@@ -1775,6 +1776,51 @@ export class AssetsController extends BaseController<
17751776
return this.state.assetsInfo[assetId] as AssetMetadata | undefined;
17761777
}
17771778

1779+
/**
1780+
* Get a single combined asset (balance + metadata + price + computed
1781+
* `fiatValue`) for an account directly from controller state.
1782+
*
1783+
* Reuses the same state-composition and filtering logic as `getAssets`
1784+
* (balance and metadata are required, a missing price falls back to
1785+
* `{ price: 0, lastUpdated: 0 }` with `fiatValue: 0`, and hidden or
1786+
* otherwise filtered assets are excluded) so the returned shape never
1787+
* drifts from `getAssets`. Reads from current state only and does not
1788+
* trigger a data-source refresh.
1789+
*
1790+
* @param accountId - The account ID (`InternalAccount.id`, not an address).
1791+
* @param assetId - The CAIP-19 asset ID including chain scope
1792+
* (e.g. `eip155:1/erc20:0x...`).
1793+
* @returns The combined `Asset`, or `undefined` when no complete
1794+
* renderable asset (balance + metadata) exists for the account/asset pair.
1795+
* @throws If `accountId` is empty or `assetId` is not a valid CAIP-19 asset ID.
1796+
*/
1797+
getAsset(accountId: AccountId, assetId: Caip19AssetId): Asset | undefined {
1798+
if (typeof accountId !== 'string' || accountId.length === 0) {
1799+
throw new Error(
1800+
'AssetsController.getAsset: accountId must be a non-empty string',
1801+
);
1802+
}
1803+
1804+
let normalizedAssetId: Caip19AssetId;
1805+
try {
1806+
normalizedAssetId = normalizeAssetId(assetId);
1807+
} catch {
1808+
throw new Error(
1809+
`AssetsController.getAsset: invalid CAIP-19 assetId "${assetId}"`,
1810+
);
1811+
}
1812+
1813+
const chainId = extractChainId(normalizedAssetId);
1814+
1815+
const assets = this.#getAssetsFromState(
1816+
[{ id: accountId }],
1817+
[chainId],
1818+
['fungible'],
1819+
);
1820+
1821+
return assets[accountId]?.[normalizedAssetId];
1822+
}
1823+
17781824
async getAssetsPrice(
17791825
accounts: InternalAccount[],
17801826
options?: {
@@ -2698,7 +2744,7 @@ export class AssetsController extends BaseController<
26982744
}
26992745

27002746
#getAssetsFromState(
2701-
accounts: InternalAccount[],
2747+
accounts: Pick<InternalAccount, 'id'>[],
27022748
chainIds: ChainId[],
27032749
assetTypes: AssetType[],
27042750
): Record<AccountId, Record<Caip19AssetId, Asset>> {
@@ -3671,6 +3717,7 @@ export class AssetsController extends BaseController<
36713717
this.messenger.unregisterActionHandler('AssetsController:getAssets');
36723718
this.messenger.unregisterActionHandler('AssetsController:getAssetsBalance');
36733719
this.messenger.unregisterActionHandler('AssetsController:getAssetMetadata');
3720+
this.messenger.unregisterActionHandler('AssetsController:getAsset');
36743721
this.messenger.unregisterActionHandler('AssetsController:getAssetsPrice');
36753722
this.messenger.unregisterActionHandler(
36763723
'AssetsController:getExchangeRatesForBridge',

packages/assets-controller/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type {
3131
AssetsControllerGetAssetsAction,
3232
AssetsControllerGetAssetsBalanceAction,
3333
AssetsControllerGetAssetMetadataAction,
34+
AssetsControllerGetAssetAction,
3435
AssetsControllerGetAssetsPriceAction,
3536
AssetsControllerAddCustomAssetAction,
3637
AssetsControllerRemoveCustomAssetAction,

0 commit comments

Comments
 (0)