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

Filter by extension

Filter by extension

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

### Changed

- Route Solana asset reads through `CoreAssetsAdapter` when the assets migration feature flag is active. Fetch and save become no-ops (Solana has no snap-owned assets), and `KeyringAccountMonitor` stops persisting balances from websocket notifications. ([#123](https://github.com/MetaMask/internal-snaps/pull/123))
- Extract Snap-owned assets domain logic into `SnapAssetsAdapter`; `AssetsService` is a thin facade that delegates metadata, market data, fetch, persist, and account asset reads through the adapter (no Core routing yet). ([#121](https://github.com/MetaMask/internal-snaps/pull/121))
- Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120))

Expand Down
2 changes: 1 addition & 1 deletion packages/solana-wallet-snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
"shasum": "2ZJZAnGhLs7mB0gCCb9ffito6qcx8KMGbteQ0wwtsYw=",
"shasum": "SWB+3GxlGtesIJB2wxYfykU0SmJbn6oonAilJxUEqBk=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import {
SNAPS_ASSETS_MIGRATION_FLAG_KEYS,
SnapsAssetsMigrationStage,
} from '@metamask/assets-controller';
import { KeyringEvent } from '@metamask/keyring-api';
import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk';
import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils';
import { cloneDeep } from 'lodash';

import type { ICache } from '../../caching/ICache';
import { InMemoryCache } from '../../caching/InMemoryCache';
import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../clients/nft-api/mocks/mockNftsListResponseMapped';
import type { NftApiClient } from '../../clients/nft-api/NftApiClient';
import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient';
import { Network } from '../../constants/solana';
import { KnownCaip19Id, Network } from '../../constants/solana';
import type { Serializable } from '../../serialization/types';
import {
MOCK_ASSET_ENTITIES,
Expand Down Expand Up @@ -36,6 +41,8 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({
describe('AssetsService', () => {
let assetsService: AssetsService;
let snapAssetsAdapter: SnapAssetsAdapter;
let coreAdapter: CoreAssetsAdapter;
let mockGetFeatureFlag: jest.Mock;
let mockConnection: SolanaConnection;
let mockConfigProvider: ConfigProvider;
let mockAssetsRepository: AssetsRepository;
Expand All @@ -45,6 +52,10 @@ describe('AssetsService', () => {
let mockNftApiClient: NftApiClient;
let mockCache: ICache<Serializable>;

const setMigrationStage = (stage: SnapsAssetsMigrationStage): void => {
mockGetFeatureFlag.mockResolvedValue({ stage });
};

beforeEach(() => {
jest.clearAllMocks();
mockConnection = createMockConnection();
Expand Down Expand Up @@ -102,7 +113,7 @@ describe('AssetsService', () => {
nftApiClient: mockNftApiClient,
});

const coreAdapter = new CoreAssetsAdapter({
coreAdapter = new CoreAssetsAdapter({
getAccountAssetByID: jest.fn().mockResolvedValue(null),
getAccountAssetsByIDs: jest.fn().mockResolvedValue({}),
getAccountAssetsByScope: jest.fn().mockResolvedValue({}),
Expand All @@ -111,9 +122,16 @@ describe('AssetsService', () => {
mockConfigProvider.getActiveNetworks.bind(mockConfigProvider),
});

mockGetFeatureFlag = jest.fn().mockResolvedValue({
stage: SnapsAssetsMigrationStage.Off,
});

assetsService = new AssetsService({
snapAdapter: snapAssetsAdapter,
coreAdapter,
remoteFeatureFlagsProvider: {
getFeatureFlag: mockGetFeatureFlag,
} as unknown as RemoteFeatureFlagsProvider,
});
});

Expand Down Expand Up @@ -824,4 +842,122 @@ describe('AssetsService', () => {
).not.toHaveBeenCalled();
});
});

describe('assets migration', () => {
const accountId = MOCK_SOLANA_KEYRING_ACCOUNT_0.id;
const activeMigrationStage =
SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback;

it('routes getAccountAssetByID through Core when migration is active', async () => {
setMigrationStage(activeMigrationStage);
jest
.spyOn(coreAdapter, 'getAccountAssetByID')
.mockResolvedValue(MOCK_ASSET_ENTITY_0);

const asset = await assetsService.getAccountAssetByID(
accountId,
KnownCaip19Id.SolMainnet,
);

expect(coreAdapter.getAccountAssetByID).toHaveBeenCalledWith(
accountId,
KnownCaip19Id.SolMainnet,
);
expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_0);
});

it('routes getAccountAssetsByIDs through Core when migration is active', async () => {
setMigrationStage(activeMigrationStage);
jest.spyOn(coreAdapter, 'getAccountAssetsByIDs').mockResolvedValue({
[KnownCaip19Id.SolMainnet]: MOCK_ASSET_ENTITY_0,
[KnownCaip19Id.UsdcMainnet]: MOCK_ASSET_ENTITY_1,
});

const results = await assetsService.getAccountAssetsByIDs(accountId, [
KnownCaip19Id.SolMainnet,
KnownCaip19Id.UsdcMainnet,
]);

expect(coreAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith(
accountId,
[KnownCaip19Id.SolMainnet, KnownCaip19Id.UsdcMainnet],
);
expect(results[KnownCaip19Id.SolMainnet]).toStrictEqual(
MOCK_ASSET_ENTITY_0,
);
expect(results[KnownCaip19Id.UsdcMainnet]).toStrictEqual(
MOCK_ASSET_ENTITY_1,
);
});

it('routes getAccountAssetsByScope through Core when migration is active', async () => {
setMigrationStage(activeMigrationStage);
jest
.spyOn(coreAdapter, 'getAccountAssetsByScope')
.mockResolvedValue([MOCK_ASSET_ENTITY_0]);

const assets = await assetsService.getAccountAssetsByScope(
Network.Mainnet,
accountId,
);

expect(coreAdapter.getAccountAssetsByScope).toHaveBeenCalledWith(
Network.Mainnet,
accountId,
);
expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]);
});

it('routes getAccountAssets through Core when migration is active', async () => {
setMigrationStage(activeMigrationStage);
jest
.spyOn(coreAdapter, 'getAccountAssets')
.mockResolvedValue([MOCK_ASSET_ENTITY_0]);

const assets = await assetsService.getAccountAssets(accountId);

expect(coreAdapter.getAccountAssets).toHaveBeenCalledWith(accountId);
expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]);
});

it('returns no assets from fetch when migration is active', async () => {
setMigrationStage(activeMigrationStage);
const snapFetchSpy = jest.spyOn(snapAssetsAdapter, 'fetch');

const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0);

expect(snapFetchSpy).not.toHaveBeenCalled();
expect(assets).toStrictEqual([]);
});

it('does not persist or emit when saveMany is called and migration is active', async () => {
setMigrationStage(activeMigrationStage);

await assetsService.saveMany([MOCK_ASSET_ENTITY_0]);

expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled();
expect(emitSnapKeyringEvent).not.toHaveBeenCalled();
});

it('reports Core assets as active when the migration flag is on', async () => {
setMigrationStage(activeMigrationStage);

expect(await assetsService.isUsingCoreAssets()).toBe(true);
});

it('reports Core assets as inactive when the migration flag is off', async () => {
expect(await assetsService.isUsingCoreAssets()).toBe(false);
});

it('reads the Solana migration flag key', async () => {
setMigrationStage(activeMigrationStage);
jest.spyOn(coreAdapter, 'getAccountAssets').mockResolvedValue([]);

await assetsService.getAccountAssets(accountId);

expect(mockGetFeatureFlag).toHaveBeenCalledWith(
SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana,
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
/* eslint-disable jsdoc/require-returns */
import {
SNAPS_ASSETS_MIGRATION_FLAG_KEYS,
SnapsAssetsMigrationStage,
parseSnapsAssetsMigrationStage,
} from '@metamask/assets-controller';
import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils';
import type { FungibleAssetMarketData } from '@metamask/snaps-sdk';
import type { CaipAssetType, CaipChainId } from '@metamask/utils';

Expand All @@ -8,31 +14,55 @@ import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter';
import type { AssetMetadata } from './types';

/**
* Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter
* (legacy snap-owned reads/writes). Core adapter is initialized for upcoming
* routing without changing callers.
* Assets domain facade. Reads use the Snap adapter while migration is off, and
* the Core adapter once migration is active. Solana has no snap-owned assets,
* so when migration is active fetch returns nothing and save is a no-op —
* Core owns fungible balances and the Snap does not persist or publish them.
*/
export class AssetsService {
readonly #snapAdapter: SnapAssetsAdapter;

// Initialized for upcoming Core routing; not read until the migration PR lands.
// eslint-disable-next-line no-unused-private-class-members -- reserved adapter slot
readonly #coreAdapter: CoreAssetsAdapter;

readonly #remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider;

readonly cacheTtlsMilliseconds: typeof SnapAssetsAdapter.cacheTtlsMilliseconds;

constructor({
snapAdapter,
coreAdapter,
remoteFeatureFlagsProvider,
}: {
snapAdapter: SnapAssetsAdapter;
coreAdapter: CoreAssetsAdapter;
remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider;
}) {
this.#snapAdapter = snapAdapter;
this.#coreAdapter = coreAdapter;
this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider;
this.cacheTtlsMilliseconds = SnapAssetsAdapter.cacheTtlsMilliseconds;
}

async #shouldReturnAssetsFromCore(): Promise<boolean> {
const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag(
SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana,
);
return (
parseSnapsAssetsMigrationStage(flagValue) !==
SnapsAssetsMigrationStage.Off
);
}

/**
* Whether asset reads come from AssetsController. When true, the Snap must
* not fetch, persist, or websocket-monitor balances — Core already does.
*
* @returns Whether the Solana assets migration flag is active.
*/
async isUsingCoreAssets(): Promise<boolean> {
return this.#shouldReturnAssetsFromCore();
}

static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean {
return SnapAssetsAdapter.hasChanged(asset, assetsLookup);
}
Expand All @@ -44,6 +74,10 @@ export class AssetsService {
}

async fetch(account: SolanaKeyringAccount): Promise<AssetEntity[]> {
if (await this.#shouldReturnAssetsFromCore()) {
return [];
}

return this.#snapAdapter.fetch(account);
}

Expand All @@ -63,7 +97,11 @@ export class AssetsService {
}

async saveMany(assets: AssetEntity[]): Promise<void> {
return this.#snapAdapter.saveMany(assets);
if (await this.#shouldReturnAssetsFromCore()) {
return;
}

await this.#snapAdapter.saveMany(assets);
}

async getAll(): Promise<AssetEntity[]> {
Expand All @@ -80,6 +118,10 @@ export class AssetsService {
accountId: string,
assetId: CaipAssetType,
): Promise<AssetEntity | null> {
if (await this.#shouldReturnAssetsFromCore()) {
return this.#coreAdapter.getAccountAssetByID(accountId, assetId);
}

return this.#snapAdapter.getAccountAssetByID(accountId, assetId);
}

Expand All @@ -94,6 +136,10 @@ export class AssetsService {
accountId: string,
assetIds: CaipAssetType[],
): Promise<Record<CaipAssetType, AssetEntity | null>> {
if (await this.#shouldReturnAssetsFromCore()) {
return this.#coreAdapter.getAccountAssetsByIDs(accountId, assetIds);
}

return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds);
}

Expand All @@ -107,6 +153,10 @@ export class AssetsService {
scope: CaipChainId,
accountId: string,
): Promise<AssetEntity[]> {
if (await this.#shouldReturnAssetsFromCore()) {
return this.#coreAdapter.getAccountAssetsByScope(scope, accountId);
}

return this.#snapAdapter.getAccountAssetsByScope(scope, accountId);
}

Expand All @@ -116,6 +166,10 @@ export class AssetsService {
* @param accountId - Keyring account ID.
*/
async getAccountAssets(accountId: string): Promise<AssetEntity[]> {
if (await this.#shouldReturnAssetsFromCore()) {
return this.#coreAdapter.getAccountAssets(accountId);
}

return this.#snapAdapter.getAccountAssets(accountId);
}

Expand Down
Loading
Loading