diff --git a/packages/snap/jest.config.js b/packages/snap/jest.config.js index 356be675..b0a65872 100644 --- a/packages/snap/jest.config.js +++ b/packages/snap/jest.config.js @@ -24,10 +24,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 68.73, - functions: 74.82, - lines: 81.74, - statements: 81.72, + branches: 69.35, + functions: 76.08, + lines: 82.03, + statements: 82.01, }, }, diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 7abe6d01..e5d01f26 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "iqzrIMsXdwSkcx/1uHjSll/HZ+3Ot9KOXHF42auXXbU=", + "shasum": "N2OkDab/a9f409c+eaizFXQam3uFx+CZ2JaBkvAcWKQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/clients/wallet/WalletMessengerClient.test.ts b/packages/snap/src/clients/wallet/WalletMessengerClient.test.ts new file mode 100644 index 00000000..03b2bebd --- /dev/null +++ b/packages/snap/src/clients/wallet/WalletMessengerClient.test.ts @@ -0,0 +1,29 @@ +import { WalletMessengerClient } from './WalletMessengerClient'; +import type { WalletMessenger } from '../../types/wallet-messenger'; + +describe('WalletMessengerClient', () => { + it('calls AssetsController:getAsset with typed arguments', async () => { + const asset = { + amount: '1000000', + metadata: { + symbol: 'TRX', + name: 'Tron', + decimals: 6, + }, + }; + const messenger: WalletMessenger = { + call: jest.fn().mockResolvedValue(asset), + }; + const client = new WalletMessengerClient(messenger); + + expect( + await client.getAsset('account-id', 'tron:728126428/slip44:195'), + ).toStrictEqual(asset); + + expect(messenger.call).toHaveBeenCalledWith( + 'AssetsController:getAsset', + 'account-id', + 'tron:728126428/slip44:195', + ); + }); +}); diff --git a/packages/snap/src/clients/wallet/WalletMessengerClient.ts b/packages/snap/src/clients/wallet/WalletMessengerClient.ts new file mode 100644 index 00000000..f931d235 --- /dev/null +++ b/packages/snap/src/clients/wallet/WalletMessengerClient.ts @@ -0,0 +1,71 @@ +import type { + RemoteFeatureFlagControllerState, + SnapAssetUpdate, + WalletMessenger, + WalletMessengerActionType, + WalletMessengerCallArgs, + WalletMessengerCallReturn, +} from '../../types/wallet-messenger'; + +/** Thin, typed wrapper around the messenger endowment supplied by MetaMask. */ +export class WalletMessengerClient { + readonly #messenger: WalletMessenger | undefined; + + constructor(messenger: WalletMessenger | undefined) { + this.#messenger = messenger; + } + + isAvailable(): boolean { + return typeof this.#messenger?.call === 'function'; + } + + call( + action: Action, + ...args: WalletMessengerCallArgs + ): WalletMessengerCallReturn { + const messenger = this.#messenger; + + if (!messenger || typeof messenger.call !== 'function') { + throw new Error('Wallet messenger is not available'); + } + + return messenger.call(action, ...args); + } + + async upsertSnapAssets( + accountId: string, + chainId: string, + assets: SnapAssetUpdate[], + ): Promise { + await this.call( + 'AssetsController:upsertSnapAssets', + accountId, + chainId, + assets, + ); + } + + async getAsset( + accountId: string, + assetId: string, + ): Promise< + | { + amount: string; + metadata?: { + symbol: string; + name: string; + decimals: number; + image?: string; + }; + } + | undefined + > { + return await this.call('AssetsController:getAsset', accountId, assetId); + } + + async getRemoteFeatureFlagState(): Promise { + return await Promise.resolve( + this.call('RemoteFeatureFlagController:getState'), + ); + } +} diff --git a/packages/snap/src/context.ts b/packages/snap/src/context.ts index 029ec8eb..df9cd337 100644 --- a/packages/snap/src/context.ts +++ b/packages/snap/src/context.ts @@ -7,6 +7,7 @@ import { TokenApiClient } from './clients/token-api/TokenApiClient'; import { TronHttpClient } from './clients/tron-http/TronHttpClient'; import { TrongridApiClient } from './clients/trongrid/TrongridApiClient'; import { TronWebFactory } from './clients/tronweb/TronWebFactory'; +import { WalletMessengerClient } from './clients/wallet/WalletMessengerClient'; import { AssetsHandler } from './handlers/assets'; import { ClientRequestHandler } from './handlers/clientRequest/clientRequest'; import { CronHandler } from './handlers/cronjob'; @@ -19,6 +20,8 @@ import { AssetsRepository } from './services/assets/AssetsRepository'; import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; import { ConfirmationHandler } from './services/confirmation/ConfirmationHandler'; +import { createStageResolver } from './services/migration/stage'; +import { TronAssetsControllerAdapter } from './services/migration/TronAssetsControllerAdapter'; import { FeeCalculatorService } from './services/send/FeeCalculatorService'; import { SendService } from './services/send/SendService'; import { StakingService } from './services/staking/StakingService'; @@ -29,6 +32,7 @@ import { TransactionScanService } from './services/transaction-scan/TransactionS import { TransactionsRepository } from './services/transactions/TransactionsRepository'; import { TransactionsService } from './services/transactions/TransactionsService'; import { WalletService } from './services/wallet/WalletService'; +import type { WalletMessenger } from './types/wallet-messenger'; import logger, { noOpLogger } from './utils/logger'; /** @@ -54,6 +58,15 @@ const state = new State({ }); const snapClient = new SnapClient({ logger }); +const walletMessengerClient = new WalletMessengerClient( + (globalThis as unknown as { messenger?: WalletMessenger }).messenger, +); +const resolveMigrationStage = createStageResolver(walletMessengerClient); +export const tronAssetsControllerAdapter = new TronAssetsControllerAdapter( + walletMessengerClient, + resolveMigrationStage, +); +export { resolveMigrationStage }; // Repositories - depend on State const accountsRepository = new AccountsRepository(state); diff --git a/packages/snap/src/services/migration/TronAssetsControllerAdapter.ts b/packages/snap/src/services/migration/TronAssetsControllerAdapter.ts new file mode 100644 index 00000000..57e02bbd --- /dev/null +++ b/packages/snap/src/services/migration/TronAssetsControllerAdapter.ts @@ -0,0 +1,104 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import type { CaipAssetType } from '@metamask/utils'; + +import { MigrationStage, type StageResolver } from './stage'; +import type { WalletMessengerClient } from '../../clients/wallet/WalletMessengerClient'; +import { type Network, TokenMetadata } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import type { SnapAssetUpdate } from '../../types/wallet-messenger'; +import { toUiAmount } from '../../utils/conversion'; + +/** + * Boundary between Tron asset synchronization and the host AssetsController. + * + * Resource and staking pseudo-assets are deliberately not filtered here: the + * controller receives the complete snapshot supplied by the Snap. + */ +export class TronAssetsControllerAdapter { + readonly #walletMessengerClient: WalletMessengerClient; + + readonly #resolveStage: StageResolver; + + #currentStage: MigrationStage = MigrationStage.Off; + + constructor( + walletMessengerClient: WalletMessengerClient, + resolveStage: StageResolver, + ) { + this.#walletMessengerClient = walletMessengerClient; + this.#resolveStage = resolveStage; + } + + async getMigrationStage(chainId: string): Promise { + await this.resolveAndSetStage(chainId); + return this.getCurrentStage(); + } + + async resolveAndSetStage(chainId: string): Promise { + this.#currentStage = await this.#resolveStage(chainId); + } + + getCurrentStage(): MigrationStage { + return this.#currentStage; + } + + async pushAssetSnapshot( + accountId: string, + chainId: string, + assets: SnapAssetUpdate[], + ): Promise { + await this.#walletMessengerClient.upsertSnapAssets( + accountId, + chainId, + assets, + ); + } + + async getAsset( + accountId: string, + assetId: string, + ): Promise { + const result = await this.#walletMessengerClient.getAsset( + accountId, + assetId, + ); + + if (!result) { + return null; + } + + return this.#mapToAssetEntity(accountId, assetId, result); + } + + #mapToAssetEntity( + accountId: string, + assetId: string, + result: { + amount: string; + metadata?: { + symbol: string; + name: string; + decimals: number; + image?: string; + }; + }, + ): AssetEntity { + const { chainId } = parseCaipAssetType(assetId as CaipAssetType); + const knownMetadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; + + const decimals = result.metadata?.decimals ?? knownMetadata?.decimals ?? 0; + const symbol = result.metadata?.symbol ?? knownMetadata?.symbol ?? ''; + const iconUrl = result.metadata?.image ?? knownMetadata?.iconUrl ?? ''; + + return { + assetType: assetId, + keyringAccountId: accountId, + network: chainId as Network, + symbol, + decimals, + rawAmount: result.amount, + uiAmount: toUiAmount(result.amount, decimals).toString(), + iconUrl, + } as AssetEntity; + } +} diff --git a/packages/snap/src/services/migration/stage.ts b/packages/snap/src/services/migration/stage.ts new file mode 100644 index 00000000..ae51f545 --- /dev/null +++ b/packages/snap/src/services/migration/stage.ts @@ -0,0 +1,167 @@ +/* eslint-disable no-restricted-globals */ + +import type { WalletMessengerClient } from '../../clients/wallet/WalletMessengerClient'; + +export enum MigrationStage { + Off = 0, + ReadAssetsControllerWithFallback = 1, + ReadAssetsControllerWithoutFallback = 2, + ReadAssetsControllerOnly = 3, +} + +export const SNAPS_ASSETS_MIGRATION_FLAG = 'snapsAssetsMigration'; + +const STAGE_ENV_VAR = 'TRON_ASSETS_MIGRATION_STAGE'; + +const VALID_STAGES = [ + MigrationStage.Off, + MigrationStage.ReadAssetsControllerWithFallback, + MigrationStage.ReadAssetsControllerWithoutFallback, + MigrationStage.ReadAssetsControllerOnly, +] as const; + +const ENV_TO_STAGE: Record = { + off: MigrationStage.Off, + '0': MigrationStage.Off, + 'read-assets-controller-with-fallback': + MigrationStage.ReadAssetsControllerWithFallback, + '1': MigrationStage.ReadAssetsControllerWithFallback, + 'read-assets-controller-without-fallback': + MigrationStage.ReadAssetsControllerWithoutFallback, + '2': MigrationStage.ReadAssetsControllerWithoutFallback, + 'read-assets-controller-only': MigrationStage.ReadAssetsControllerOnly, + '3': MigrationStage.ReadAssetsControllerOnly, +}; + +type SnapsAssetsMigrationFlag = { + stages?: Partial>; + killSwitch?: boolean; +}; + +export type StageResolver = (chainId: string) => Promise; + +/** + * Returns true when the Snap is running outside production. + * + * @returns Whether the current build is non-production. + */ +function isDevEnvironment(): boolean { + return process.env.ENVIRONMENT !== 'production'; +} + +/** + * Reads the migration stage from `TRON_ASSETS_MIGRATION_STAGE`. + * + * @returns The configured stage, or `undefined` when unset or invalid. + */ +function resolveStageFromEnv(): MigrationStage | undefined { + const rawStage = process.env[STAGE_ENV_VAR]?.trim().toLowerCase(); + + if (rawStage && rawStage in ENV_TO_STAGE) { + return ENV_TO_STAGE[rawStage]; + } + + return undefined; +} + +/** + * Extracts the migration stage for a chain from remote feature flags. + * + * @param remoteFeatureFlags - Remote feature flag payload from the wallet. + * @param chainId - CAIP-2 chain id for the network. + * @returns The configured stage, or `undefined` when absent or invalid. + */ +function parseStageFromRemoteFlags( + remoteFeatureFlags: Record | undefined, + chainId: string, +): MigrationStage | undefined { + if (!remoteFeatureFlags) { + return undefined; + } + + const flagValue = remoteFeatureFlags[SNAPS_ASSETS_MIGRATION_FLAG]; + + if (!flagValue || typeof flagValue !== 'object') { + return undefined; + } + + const flag = flagValue as SnapsAssetsMigrationFlag; + + if (flag.killSwitch === true) { + return MigrationStage.Off; + } + + const stage = flag.stages?.[chainId]; + + if ( + stage === undefined || + stage === null || + typeof stage !== 'number' || + !VALID_STAGES.includes(stage) + ) { + return undefined; + } + + return stage; +} + +/** + * Resolves the assets migration stage for a network. + * + * Resolution order: + * 1. `RemoteFeatureFlagController:getState` via wallet messenger (when available) + * 2. `process.env.TRON_ASSETS_MIGRATION_STAGE` in non-production builds + * 3. {@link MigrationStage.Off} + * + * @param chainId - CAIP-2 chain id for the network. + * @param walletMessengerClient - Typed wallet messenger client. + * @returns The resolved migration stage. + */ +export async function resolveStage( + chainId: string, + walletMessengerClient: WalletMessengerClient, +): Promise { + const envStage = resolveStageFromEnv(); + + if (walletMessengerClient.isAvailable()) { + try { + const state = await walletMessengerClient.getRemoteFeatureFlagState(); + const flagStage = parseStageFromRemoteFlags( + state.remoteFeatureFlags, + chainId, + ); + + if (flagStage !== undefined) { + return flagStage; + } + } catch { + // Fall through to env/default handling. + } + } + + if (isDevEnvironment() && envStage !== undefined) { + return envStage; + } + + return MigrationStage.Off; +} + +/** + * Creates a chain-scoped stage resolver bound to a messenger client. + * + * @param walletMessengerClient - Typed wallet messenger client. + * @returns Async resolver for migration stages. + */ +export function createStageResolver( + walletMessengerClient: WalletMessengerClient, +): StageResolver { + return async (chainId: string) => + resolveStage(chainId, walletMessengerClient); +} + +/** + * Clears the dev env override. Intended for tests only. + */ +export function resetMigrationStageEnvForTests(): void { + delete process.env[STAGE_ENV_VAR]; +} diff --git a/packages/snap/src/types/wallet-messenger.ts b/packages/snap/src/types/wallet-messenger.ts new file mode 100644 index 00000000..75dd0d72 --- /dev/null +++ b/packages/snap/src/types/wallet-messenger.ts @@ -0,0 +1,78 @@ +/** + * Asset data supplied by a Snap to the host AssetsController. + * + * This is intentionally local while the corresponding Core action is being + * finalized. + */ +export type SnapAssetUpdate = { + assetId: string; + amount: string; + metadata: { + symbol: string; + name: string; + decimals: number; + image?: string; + }; +}; + +export type AssetsControllerUpsertSnapAssetsAction = { + type: 'AssetsController:upsertSnapAssets'; + handler: ( + accountId: string, + chainId: string, + assets: SnapAssetUpdate[], + ) => Promise; +}; + +export type AssetsControllerGetAssetAction = { + type: 'AssetsController:getAsset'; + handler: ( + accountId: string, + assetId: string, + ) => Promise< + | { + amount: string; + metadata?: { + symbol: string; + name: string; + decimals: number; + image?: string; + }; + } + | undefined + >; +}; + +export type RemoteFeatureFlagControllerState = { + remoteFeatureFlags: Record; +}; + +export type RemoteFeatureFlagControllerGetStateAction = { + type: 'RemoteFeatureFlagController:getState'; + handler: () => RemoteFeatureFlagControllerState; +}; + +export type WalletMessengerActions = + | AssetsControllerUpsertSnapAssetsAction + | AssetsControllerGetAssetAction + | RemoteFeatureFlagControllerGetStateAction; + +export type WalletMessengerActionType = WalletMessengerActions['type']; + +type ActionHandlerMap = { + [Action in WalletMessengerActions as Action['type']]: Action['handler']; +}; + +export type WalletMessengerCallArgs = + Parameters; + +export type WalletMessengerCallReturn< + Action extends WalletMessengerActionType, +> = ReturnType; + +export type WalletMessenger = { + call: ( + action: Action, + ...args: WalletMessengerCallArgs + ) => WalletMessengerCallReturn; +};