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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/snap-networks-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add package scaffold with an example `logger` module for shared network-snap utilities ([#79](https://github.com/MetaMask/internal-snaps/pull/79))
- Add shared `AssetsService` under `services/assets` for Core AssetsController reads, plus `mapControllerAsset` and `toUiAmount` helpers ([#80](https://github.com/MetaMask/internal-snaps/pull/80))

[Unreleased]: https://github.com/MetaMask/internal-snaps/
11 changes: 10 additions & 1 deletion packages/snap-networks-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ yarn workspace @metamask/tron-wallet-snap add @metamask/snap-networks-utils@work

## Usage

Import the example logger from the package root or the dedicated subpath:
### Logger

```typescript
import { logger, createPrefixedLogger } from '@metamask/snap-networks-utils';
Expand All @@ -28,6 +28,15 @@ const snapLogger = createPrefixedLogger(logger, '[tron-wallet-snap]');
snapLogger.info('account synced');
```

### AssetsController reads

```typescript
import { AssetsService } from '@metamask/snap-networks-utils';

const assetsService = new AssetsService({ coreMessenger });
const asset = await assetsService.getAccountAssetByID(accountId, assetId);
```

## Contributing

This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/internal-snaps#readme).
7 changes: 7 additions & 0 deletions packages/snap-networks-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@metamask/assets-controller": "11.2.0",
"@metamask/keyring-internal-api": "^11.0.1",
"@metamask/messenger": "^2.0.0",
"@metamask/snaps-sdk": "^11.2.0",
"@metamask/utils": "^11.9.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^6.1.1",
"@ts-bridge/cli": "^0.6.4",
Expand Down
11 changes: 11 additions & 0 deletions packages/snap-networks-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
export type { Logger } from './logger';
export { createPrefixedLogger, logger, noOpLogger } from './logger';
export { AssetsService } from './services/assets/AssetsService';
export type { AssetEntity, AssetScope } from './services/assets/types';
export { mapControllerAsset } from './services/assets/utils/mapControllerAsset';
export type {
AssetsControllerGetAssetAction,
AssetsControllerGetAssetsAction,
CoreMessenger,
CoreMessengerActions,
CoreMessengerCaller,
} from './types/core-messenger';
export { toUiAmount } from './utils/toUiAmount';
171 changes: 171 additions & 0 deletions packages/snap-networks-utils/src/services/assets/AssetsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { AccountId, Asset } from '@metamask/assets-controller';
import type { CaipChainId } from '@metamask/utils';

import type { CoreMessengerCaller } from '../../types/core-messenger';
import { AssetsService } from './AssetsService';

const ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000' as AccountId;
const ASSET_ID = 'tron:728126428/slip44:195';
const CHAIN_ID = 'tron:728126428' as CaipChainId;

const controllerAsset = {
balance: { amount: '1000000' },
metadata: {
symbol: 'TRX',
decimals: 6,
image: 'https://example.com/trx.png',
},
} as unknown as Asset;

const mappedAsset = {
assetType: ASSET_ID,
keyringAccountId: ACCOUNT_ID,
network: 'tron:728126428',
symbol: 'TRX',
decimals: 6,
rawAmount: '1000000',
uiAmount: '1',
iconUrl: 'https://example.com/trx.png',
};

type WithAssetsServiceCallback<ReturnValue> = (payload: {
assetsService: AssetsService;
mockCoreMessenger: jest.Mocked<Pick<CoreMessengerCaller, 'call'>>;
}) => Promise<ReturnValue> | ReturnValue;

/**
* Wraps tests for AssetsService by creating a fresh service with a mock Core
* messenger. The callback receives the service and mock for test configuration.
*
* @param testFunction - The test body receiving the service and mocks.
* @returns The return value of the callback.
*/
async function withAssetsService<ReturnValue>(
testFunction: WithAssetsServiceCallback<ReturnValue>,
): Promise<ReturnValue> {
const mockCoreMessenger: jest.Mocked<Pick<CoreMessengerCaller, 'call'>> = {
call: jest.fn(),
};

const assetsService = new AssetsService({
coreMessenger: mockCoreMessenger,
});

return await testFunction({
assetsService,
mockCoreMessenger,
});
}

describe('AssetsService', () => {
describe('getAccountAssetByID', () => {
it('returns a mapped asset when the controller has it', async () => {
await withAssetsService(async ({ assetsService, mockCoreMessenger }) => {
mockCoreMessenger.call.mockResolvedValue(controllerAsset);

expect(
await assetsService.getAccountAssetByID(ACCOUNT_ID, ASSET_ID),
).toStrictEqual(mappedAsset);

expect(mockCoreMessenger.call).toHaveBeenCalledWith(
'AssetsController:getAsset',
ACCOUNT_ID,
ASSET_ID,
);
});
});

it('returns null when the controller has no asset', async () => {
await withAssetsService(async ({ assetsService, mockCoreMessenger }) => {
mockCoreMessenger.call.mockResolvedValue(undefined);

expect(
await assetsService.getAccountAssetByID(ACCOUNT_ID, ASSET_ID),
).toBeNull();
});
});
});

describe('getAccountAssetsByIDs', () => {
it('returns an empty map for an empty request', async () => {
await withAssetsService(async ({ assetsService, mockCoreMessenger }) => {
expect(
await assetsService.getAccountAssetsByIDs(ACCOUNT_ID, []),
).toStrictEqual({});
expect(mockCoreMessenger.call).not.toHaveBeenCalled();
});
});

it('returns a map keyed by asset ID with nulls for missing assets', async () => {
const missingId = 'tron:728126428/trc20:missing';

await withAssetsService(async ({ assetsService, mockCoreMessenger }) => {
mockCoreMessenger.call.mockResolvedValue({
[ACCOUNT_ID]: {
[ASSET_ID]: controllerAsset,
},
});

expect(
await assetsService.getAccountAssetsByIDs(ACCOUNT_ID, [
missingId,
ASSET_ID,
]),
).toStrictEqual({
[missingId]: null,
[ASSET_ID]: mappedAsset,
});

expect(mockCoreMessenger.call).toHaveBeenCalledWith(
'AssetsController:getAssets',
[{ id: ACCOUNT_ID }],
{ chainIds: [CHAIN_ID] },
);
});
});

it('treats a missing account entry as an empty asset map', async () => {
await withAssetsService(async ({ assetsService, mockCoreMessenger }) => {
mockCoreMessenger.call.mockResolvedValue({});

expect(
await assetsService.getAccountAssetsByIDs(ACCOUNT_ID, [ASSET_ID]),
).toStrictEqual({
[ASSET_ID]: null,
});
});
});
});

describe('getAccountAssetsByScope', () => {
it('returns all mapped controller assets for an account', async () => {
await withAssetsService(async ({ assetsService, mockCoreMessenger }) => {
mockCoreMessenger.call.mockResolvedValue({
[ACCOUNT_ID]: {
[ASSET_ID]: controllerAsset,
},
});

expect(
await assetsService.getAccountAssetsByScope(CHAIN_ID, ACCOUNT_ID),
).toStrictEqual([mappedAsset]);

expect(mockCoreMessenger.call).toHaveBeenCalledWith(
'AssetsController:getAssets',
[{ id: ACCOUNT_ID }],
{ chainIds: [CHAIN_ID] },
);
});
});

it('returns an empty list when the account is missing from the response', async () => {
await withAssetsService(async ({ assetsService, mockCoreMessenger }) => {
mockCoreMessenger.call.mockResolvedValue({});

expect(
await assetsService.getAccountAssetsByScope(CHAIN_ID, ACCOUNT_ID),
).toStrictEqual([]);
});
});
});
});
126 changes: 126 additions & 0 deletions packages/snap-networks-utils/src/services/assets/AssetsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type {
AccountId,
Asset,
Caip19AssetId,
} from '@metamask/assets-controller';
import type { InternalAccount } from '@metamask/keyring-internal-api';
import type { CaipAssetType, CaipChainId } from '@metamask/utils';
import { parseCaipAssetType } from '@metamask/utils';

import type { CoreMessengerCaller } from '../../types/core-messenger';
import type { AssetEntity } from './types';
import { mapControllerAsset } from './utils/mapControllerAsset';

/**
* Thin service that reads account assets from Core AssetsController via a
* Snap messenger endowment.
*
* Does not handle snap-owned / protocol-specific assets.
*/
export class AssetsService {
readonly #coreMessenger: CoreMessengerCaller;

constructor({ coreMessenger }: { coreMessenger: CoreMessengerCaller }) {
this.#coreMessenger = coreMessenger;
}

/**
* Returns a single account asset by CAIP-19 ID, or `null` if missing.
*
* @param accountId - Keyring account ID.
* @param assetId - CAIP-19 asset ID.
* @returns Mapped asset, or `null`.
*/
async getAccountAssetByID(
accountId: AccountId,
assetId: string,
): Promise<AssetEntity | null> {
const result = await this.#coreMessenger.call(
'AssetsController:getAsset',
accountId,
assetId as Caip19AssetId,
);

if (!result) {
return null;
}

return mapControllerAsset(accountId, assetId, result);
}

/**
* Returns account assets for the given CAIP-19 IDs, keyed by asset ID.
* Missing assets are `null`.
*
* @param accountId - Keyring account ID.
* @param assetIds - CAIP-19 asset IDs to resolve.
* @returns Map of asset ID → mapped asset (or `null` when missing).
*/
async getAccountAssetsByIDs(
accountId: AccountId,
assetIds: string[],
): Promise<Record<string, AssetEntity | null>> {
if (assetIds.length === 0) {
return {};
}

/**
* Get the unique chain IDs from the asset IDs.
*/
const chainIds = [
...new Set(
assetIds.map(
(assetId) => parseCaipAssetType(assetId as CaipAssetType).chainId,
),
),
];

const controllerAssets = await this.#coreMessenger.call(
'AssetsController:getAssets',
[{ id: accountId } as InternalAccount],
{ chainIds },
);

const accountAssets =
(controllerAssets as Record<string, Record<string, Asset>>)[accountId] ??
{};

return Object.fromEntries(
assetIds.map((assetId) => {
const controllerAsset = accountAssets[assetId];
return [
assetId,
controllerAsset
? mapControllerAsset(accountId, assetId, controllerAsset)
: null,
];
}),
);
}

/**
* Returns all controller-backed assets for an account.
*
* @param scope - CAIP-2 chain ID to filter controller results.
* @param accountId - Keyring account ID.
* @returns Mapped assets present in controller state.
*/
async getAccountAssetsByScope(
scope: CaipChainId,
accountId: AccountId,
): Promise<AssetEntity[]> {
const controllerAssets = await this.#coreMessenger.call(
'AssetsController:getAssets',
[{ id: accountId } as InternalAccount],
{ chainIds: [scope] },
);

const accountAssets =
(controllerAssets as Record<string, Record<string, Asset>>)[accountId] ??
{};

return Object.entries(accountAssets).map(([assetId, asset]) =>
mapControllerAsset(accountId, assetId, asset),
);
}
}
27 changes: 27 additions & 0 deletions packages/snap-networks-utils/src/services/assets/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { AccountId } from '@metamask/assets-controller';
import type { CaipChainId } from '@metamask/utils';

/**
* Generic Snap asset shape produced from Core AssetsController reads.
*/
export type AssetEntity = {
/** CAIP-19 asset ID. */
assetType: string;
/** Keyring account ID (`InternalAccount.id`). */
keyringAccountId: AccountId;
/** CAIP-2 chain ID. */
network: string;
symbol: string;
decimals: number;
/** Balance in smallest units (no decimals applied). */
rawAmount: string;
/** Human-readable balance with decimals applied. */
uiAmount: string;
iconUrl: string;
};

/**
* CAIP-2 chain ID filter for AssetsController reads.
* Matches the Accounts-domain `scope` naming used across snaps.
*/
export type AssetScope = CaipChainId | readonly CaipChainId[];
Loading
Loading