diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 807c39d53..eca9a4b9a 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Reject scopes that are not supported Tron networks instead of accepting them unchecked ([#394](https://github.com/MetaMask/internal-snaps/pull/394)) + - `onAmountInput`, `confirmSend`, `claimUnstakedTrx`, `claimTrxStakingRewards` and `resolveAccountAddress` now throw an `InvalidParamsError` for an asset or scope on an unsupported chain. + - Assets returned by the AssetsController on an unsupported chain now throw instead of being mapped. - Render the confirmation "Estimated changes" section with the shared `EstimatedChanges` component from `@metamask/snap-networks-utils` ([#396](https://github.com/MetaMask/internal-snaps/pull/396)) - Estimated changes from the previous scan now stay visible while a confirmation re-scans, instead of showing a loading skeleton diff --git a/packages/tron-wallet-snap/jest.config.js b/packages/tron-wallet-snap/jest.config.js index 947d0368e..af0db6b55 100644 --- a/packages/tron-wallet-snap/jest.config.js +++ b/packages/tron-wallet-snap/jest.config.js @@ -16,13 +16,16 @@ module.exports = { '/jest.setup.ts', ], + // Measure index.ts barrels (e.g. src/constants/index.ts) so SonarCloud new-code coverage sees them; the shared base config's blanket index.ts exclusion zeroes them in lcov. + coveragePathIgnorePatterns: ['jest\\.setup\\.ts$'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 72.49, - functions: 79.91, - lines: 85.79, - statements: 85.79, + branches: 73.09, + functions: 80.43, + lines: 86.39, + statements: 86.38, }, }, }; diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 770eb56b0..5cc9ffac3 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "1IlkNeNaW28YQoE0wK3OdDJctxoEBDqK6cxLQ0GyifY=", + "shasum": "R0LTwZZs0pys5NIQLXqxoG1g3q0Bllj+ginLZD78rMk=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -68,7 +68,8 @@ "RemoteFeatureFlagController:getState", "AssetsController:getAccountAssetByID", "AssetsController:getAccountAssetsByIDs", - "AssetsController:getAccountAssetsByScope" + "AssetsController:getAccountAssetsByScope", + "AssetsController:getAssets" ] } }, diff --git a/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts b/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts index 9d4119803..70ff9aa57 100644 --- a/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts +++ b/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts @@ -3,7 +3,8 @@ import type { Logger } from '@metamask/snap-networks-utils'; import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; import { array, assert } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; -import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; +import { CaipAssetTypeStruct } from '@metamask/utils'; +import { parseTronCaipAssetType } from '../../utils/caip'; import { Network, SNAP_OWNED_ASSETS } from '../../constants'; import type { TokenCaipAssetType } from '../../services/assets/types'; @@ -96,8 +97,8 @@ export class TokenApiClient { if (SNAP_OWNED_ASSETS.includes(assetType)) { return false; } - const { chainId } = parseCaipAssetType(assetType); - return TokenApiClient.supportedNetworks.includes(chainId as Network); + const { chainId } = parseTronCaipAssetType(assetType); + return TokenApiClient.supportedNetworks.includes(chainId); }); if (supportedAssetTypes.length !== assetTypes.length) { diff --git a/packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts b/packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts index 7a4bfbed3..d372ad31a 100644 --- a/packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts +++ b/packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts @@ -1,7 +1,7 @@ import { buildUrl } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/superstruct'; -import type { Network } from '../../constants'; +import { Network } from '../../constants'; import type { ConfigProvider } from '../../services/config'; import { hexToString } from '../../utils/hex'; import { @@ -43,14 +43,14 @@ export class TronHttpClient { const { baseUrls } = configProvider.config.tronHttpApi; // Initialize clients for all networks - Object.entries(baseUrls).forEach(([network, baseUrl]) => { + Object.values(Network).forEach((network) => { const headers: Record = { 'Content-Type': 'application/json', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Origin': '*', }; - this.#clients.set(network as Network, { baseUrl, headers }); + this.#clients.set(network, { baseUrl: baseUrls[network], headers }); }); } diff --git a/packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts b/packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts index fb852a9a0..73b3034ca 100644 --- a/packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts +++ b/packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts @@ -6,7 +6,7 @@ import type { } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/superstruct'; -import type { Network } from '../../constants'; +import { Network } from '../../constants'; import type { ConfigProvider } from '../../services/config'; import type { TronHttpClient } from '../tron-http/TronHttpClient'; import type { ChainParameter } from '../tron-http/types'; @@ -80,14 +80,14 @@ export class TrongridApiClient { const { baseUrls } = configProvider.config.trongridApi; // Initialize clients for all networks - Object.entries(baseUrls).forEach(([network, baseUrl]) => { + Object.values(Network).forEach((network) => { const headers: Record = { 'Content-Type': 'application/json', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Origin': '*', }; - this.#clients.set(network as Network, { baseUrl, headers }); + this.#clients.set(network, { baseUrl: baseUrls[network], headers }); }); this.#tronHttpClient = tronHttpClient; diff --git a/packages/tron-wallet-snap/src/constants/index.ts b/packages/tron-wallet-snap/src/constants/index.ts index b288f05be..37ebc57f7 100644 --- a/packages/tron-wallet-snap/src/constants/index.ts +++ b/packages/tron-wallet-snap/src/constants/index.ts @@ -1,3 +1,4 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; import { BigNumber } from 'bignumber.js'; export const ZERO = BigNumber(0); @@ -66,6 +67,19 @@ export const Network = { export type Network = (typeof Network)[keyof typeof Network]; +const NETWORK_SCOPES: readonly string[] = Object.values(Network); + +export const isSupportedNetwork = (scope: string): scope is Network => + NETWORK_SCOPES.includes(scope); + +export const assertSupportedNetwork = (scope: string): Network => { + if (!isSupportedNetwork(scope)) { + throw new InvalidParamsError(`Unsupported network: "${scope}"`) as Error; + } + + return scope; +}; + export const KnownCaip19Id = { TrxMainnet: `${Network.Mainnet}/slip44:195`, TrxNile: `${Network.Nile}/slip44:195`, diff --git a/packages/tron-wallet-snap/src/entities/tronKeyringAccount.ts b/packages/tron-wallet-snap/src/entities/tronKeyringAccount.ts new file mode 100644 index 000000000..2c4050d8d --- /dev/null +++ b/packages/tron-wallet-snap/src/entities/tronKeyringAccount.ts @@ -0,0 +1,7 @@ +import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; + +import type { Network } from '../constants'; + +export type TronKeyringAccount = Omit & { + scopes: Network[]; +}; diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts index f79b71121..4744cc773 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts @@ -18,7 +18,6 @@ import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import { FALLBACK_FEE, FEE_LIMIT, - METAMASK_ORIGIN, Network, Networks, TRACK_TX_INTERVAL, @@ -117,8 +116,11 @@ type WithClientRequestHandlerCallback = (payload: { Pick >; mockAssetsService: jest.Mocked< - Pick - >; + Pick< + AssetsService, + | 'getFreshAccountAssetsByIDs' + | 'getFreshAccountAssetByID' + >; mockSendService: jest.Mocked< Pick< SendService, @@ -167,10 +169,14 @@ async function withClientRequestHandler( }; const mockAssetsService: jest.Mocked< - Pick + Pick< + AssetsService, + | 'getFreshAccountAssetsByIDs' + | 'getFreshAccountAssetByID' + > > = { - getAccountAssetsByIDs: jest.fn(), - getAccountAssetByID: jest.fn(), + getFreshAccountAssetsByIDs: jest.fn(), + getFreshAccountAssetByID: jest.fn(), }; const mockSendService: jest.Mocked< @@ -322,7 +328,8 @@ describe('ClientRequestHandler', () => { } as unknown as jest.Mocked; mockAssetsService = { - getAccountAssetsByIDs: jest.fn(), + getFreshAccountAssetsByIDs: jest.fn().mockResolvedValue([]), + getFreshAccountAssetByID: jest.fn().mockResolvedValue(null), } as unknown as jest.Mocked; mockSendService = {} as unknown as jest.Mocked; @@ -758,7 +765,7 @@ describe('ClientRequestHandler', () => { mockTronWeb.trx.sign.mockResolvedValue(signedTransaction); // Mock available resources - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue([ + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue([ { rawAmount: '5000' }, // Bandwidth { rawAmount: '100000' }, // Energy ] as any); @@ -803,8 +810,10 @@ describe('ClientRequestHandler', () => { ).toHaveBeenCalledWith('TriggerSmartContract', expect.any(String)); // trx.sign is NOT called - fee computation uses unsigned transactions expect(mockTronWeb.trx.sign).not.toHaveBeenCalled(); - expect(mockAssetsService.getAccountAssetsByIDs).toHaveBeenCalledWith( - TEST_ACCOUNT_ID, + expect( + mockAssetsService.getFreshAccountAssetsByIDs, + ).toHaveBeenCalledWith( + expect.objectContaining({ id: TEST_ACCOUNT_ID }), [Networks[scope].bandwidth.id, Networks[scope].energy.id], ); // computeFee receives unsigned transaction (no signature field) @@ -895,7 +904,7 @@ describe('ClientRequestHandler', () => { }; mockTronWeb.trx.sign.mockResolvedValue(signedTransaction); - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue([ + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue([ { rawAmount: '1000' }, // Bandwidth { rawAmount: '0' }, // Energy (not needed for native transfer) ] as any); @@ -988,7 +997,7 @@ describe('ClientRequestHandler', () => { }); // No resources available - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue([ + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue([ undefined, // No bandwidth asset undefined, // No energy asset ] as any); @@ -1759,7 +1768,9 @@ describe('ClientRequestHandler - signAndSendTransaction', () => { } as unknown as jest.Mocked; mockAssetsService = { - getAccountAssetsByIDs: jest.fn(), + getFreshAccountAssetsByIDs: jest.fn(), + getFreshAccountAssetsByIDs: jest.fn().mockResolvedValue([]), + getFreshAccountAssetByID: jest.fn().mockResolvedValue(null), } as unknown as jest.Mocked; mockSendService = {} as unknown as jest.Mocked; @@ -1968,7 +1979,7 @@ describe('ClientRequestHandler - signAndSendTransaction', () => { expect(mockAnalyticsService.trackTransactionSubmitted).toHaveBeenCalledWith( { - origin: METAMASK_ORIGIN, + origin: 'MetaMask', accountType: 'tron:eoa', chainIdCaip: scope, transactionType: TransactionType.Unknown, @@ -2113,7 +2124,9 @@ describe('ClientRequestHandler - onAmountInput', () => { ]; mockAccountsService.findById.mockResolvedValue(mockAccount); - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue( + mockAssets, + ); const result = await handler.handle(request); @@ -2171,7 +2184,9 @@ describe('ClientRequestHandler - onAmountInput', () => { ]; mockAccountsService.findById.mockResolvedValue(mockAccount); - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue( + mockAssets, + ); mockSendService.buildTransaction.mockResolvedValue(builtTransaction); mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); @@ -2243,7 +2258,9 @@ describe('ClientRequestHandler - onAmountInput', () => { ]; mockAccountsService.findById.mockResolvedValue(mockAccount); - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue( + mockAssets, + ); mockSendService.buildTransaction.mockResolvedValue(builtTransaction); mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); @@ -2294,7 +2311,9 @@ describe('ClientRequestHandler - onAmountInput', () => { ]; mockAccountsService.findById.mockResolvedValue(mockAccount); - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue( + mockAssets, + ); const result = await handler.handle(request); @@ -2355,7 +2374,9 @@ describe('ClientRequestHandler - onAmountInput', () => { ]; mockAccountsService.findById.mockResolvedValue(mockAccount); - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue(mockAssets); + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue( + mockAssets, + ); mockSendService.buildTransaction.mockResolvedValue(builtTransaction); mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); @@ -2444,14 +2465,12 @@ describe('ClientRequestHandler - computeStakeFee', () => { // Native TRX asset for mainnet const nativeAssetId = Networks[scope].nativeToken.id; - // Mock native balance and resources - mockAssetsService.getAccountAssetByID.mockResolvedValue({ - uiAmount: '100', - } as AssetEntity); - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue([ + // Mock native balance and resources (single fresh fetch) + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue([ + { uiAmount: '100' }, // Native TRX { rawAmount: '5000' }, // Bandwidth { rawAmount: '100000' }, // Energy - ] as AssetEntity[]); + ] as unknown as AssetEntity[]); const feeResult = [ { @@ -2481,13 +2500,15 @@ describe('ClientRequestHandler - computeStakeFee', () => { 'ENERGY', 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', ); - expect(mockAssetsService.getAccountAssetByID).toHaveBeenCalledWith( - TEST_ACCOUNT_ID, - nativeAssetId, - ); - expect(mockAssetsService.getAccountAssetsByIDs).toHaveBeenCalledWith( - TEST_ACCOUNT_ID, - [Networks[scope].bandwidth.id, Networks[scope].energy.id], + expect( + mockAssetsService.getFreshAccountAssetsByIDs, + ).toHaveBeenCalledWith( + expect.objectContaining({ id: TEST_ACCOUNT_ID }), + [ + nativeAssetId, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ], ); // computeFee receives unsigned transaction (no signature field) expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ @@ -2530,9 +2551,9 @@ describe('ClientRequestHandler - computeStakeFee', () => { } as any); // Account has only 5 TRX - mockAssetsService.getAccountAssetByID.mockResolvedValue({ - uiAmount: '5', - } as AssetEntity); + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue([ + { uiAmount: '5' }, // Native TRX + ] as unknown as AssetEntity[]); const result = await handler.handle(request as JsonRpcRequest); @@ -2588,7 +2609,9 @@ describe('ClientRequestHandler - confirmSend validation', () => { uiAmount: '100', rawAmount: '100000000', } as NativeAsset; - mockAssetsService.getAccountAssetByID.mockResolvedValue(mockAsset); + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue([ + mockAsset, + ]); // validateSend returns insufficient balance mockSendService.validateSend.mockResolvedValue({ @@ -2657,7 +2680,9 @@ describe('ClientRequestHandler - confirmSend validation', () => { uiAmount: '100', rawAmount: '100000000', } as NativeAsset; - mockAssetsService.getAccountAssetByID.mockResolvedValue(mockAsset); + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue([ + mockAsset, + ]); // validateSend returns insufficient balance to cover fee mockSendService.validateSend.mockResolvedValue({ @@ -2719,13 +2744,12 @@ describe('ClientRequestHandler - confirmSend validation', () => { uiAmount: '100', rawAmount: '100000000', } as NativeAsset; - mockAssetsService.getAccountAssetByID.mockResolvedValue(mockAsset); - // validateSend returns valid. mockSendService.validateSend.mockResolvedValue({ valid: true }); // Mock the rest of the flow. - mockAssetsService.getAccountAssetsByIDs.mockResolvedValue([ + mockAssetsService.getFreshAccountAssetsByIDs.mockResolvedValue([ + mockAsset, { rawAmount: '1000' }, // Bandwidth { rawAmount: '50000' }, // Energy ] as any); @@ -2881,9 +2905,9 @@ describe('ClientRequestHandler - confirmSend validation', () => { } as any); // Asset not found - (mockAssetsService.getAccountAssetByID as jest.Mock).mockResolvedValue( - null, - ); + ( + mockAssetsService.getFreshAccountAssetsByIDs as jest.Mock + ).mockResolvedValue([null, null, null]); const result = await handler.handle(request); @@ -2998,6 +3022,28 @@ describe('ClientRequestHandler - claimUnstakedTrx', () => { ); }); }); + + it('rejects foreign CAIP asset IDs at the boundary', async () => { + await withClientRequestHandler(async ({ handler, mockAccountsService }) => { + mockAccountsService.findById.mockResolvedValue(mockAccount); + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: 'eip155:1/slip44:60', + value: '10', + }, + }; + + await expect(handler.handle(request)).resolves.toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + }); }); describe('ClientRequestHandler - claimTrxStakingRewards', () => { diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 898e04599..21fa43d62 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -12,12 +12,8 @@ import { UserRejectedRequestError, } from '@metamask/snaps-sdk'; import { assert } from '@metamask/superstruct'; -import { - bytesToHex, - hexToBytes, - parseCaipAssetType, - sha256, -} from '@metamask/utils'; +import { bytesToHex, hexToBytes, sha256 } from '@metamask/utils'; +import { parseTronCaipAssetType } from '../../utils/caip'; import { BigNumber } from 'bignumber.js'; import type { TronWeb, Types as TronwebTypes } from 'tronweb'; @@ -26,7 +22,6 @@ import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import { FALLBACK_FEE, FEE_LIMIT, - METAMASK_ORIGIN, Network, Networks, TRACK_TX_INTERVAL, @@ -413,15 +408,14 @@ export class ClientRequestHandler { await this.#transactionsService.save(pendingTransaction); /** - * Client requests come from MetaMask's own unified send flow, matching the - * unified send path and the background transaction tracker. The origin is - * lowercased so it is recognized as MetaMask by the security alerts scan - * and stays consistent with the other non-EVM snaps. + * Origin is 'MetaMask' because client requests come from MetaMask's own + * unified send flow, matching the unified send path and the background + * transaction tracker. */ const transactionType = mapRawTransactionType(rawData); await this.#analyticsService.trackTransactionSubmitted({ - origin: METAMASK_ORIGIN, + origin: 'MetaMask', accountType: account.type, chainIdCaip: scope, transactionType, @@ -497,11 +491,10 @@ export class ClientRequestHandler { /** * Check if we have enough of the asset we want to send... */ - const { chainId } = parseCaipAssetType(assetId); - const scope = chainId as Network; + const { chainId: scope } = parseTronCaipAssetType(assetId); const [asset, nativeTokenAsset, bandwidthAsset, energyAsset] = - await this.#assetsService.getAccountAssetsByIDs(accountId, [ + await this.#assetsService.getFreshAccountAssetsByIDs(account, [ assetId, Networks[scope].nativeToken.id, Networks[scope].bandwidth.id, @@ -614,10 +607,18 @@ export class ClientRequestHandler { }; } - const asset = await this.#assetsService.getAccountAssetByID( - fromAccountId, - assetId, - ); + const { chainId: scope } = parseTronCaipAssetType(assetId); + + /** + * Fetch the sent asset and the account resources fresh, so the + * validation and fee estimation below never act on stale balances. + */ + const [asset, bandwidthAsset, energyAsset] = + await this.#assetsService.getFreshAccountAssetsByIDs(account, [ + assetId, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); if (!asset) { return { @@ -626,9 +627,6 @@ export class ClientRequestHandler { }; } - const { chainId } = parseCaipAssetType(assetId); - const scope = chainId as Network; - const amountBN = new BigNumber(amount); /** @@ -654,27 +652,13 @@ export class ClientRequestHandler { }; } - const [[bandwidthAsset, energyAsset], transaction] = await Promise.all([ - /** - * Get available Energy and Bandwidth from account assets. - */ - this.#assetsService.getAccountAssetsByIDs(fromAccountId, [ - Networks[scope].bandwidth.id, - Networks[scope].energy.id, - ]), - /** - * Build the unsigned transaction. - * Fee estimation uses a constant overhead for the signature (134 bytes). - * Signing happens after user confirmation in sendTransaction(). - */ - this.#sendService.buildTransaction({ - fromAccountId, - toAddress, - asset, - amount: amountBN, - feeLimit: FEE_LIMIT, - }), - ]); + const transaction = await this.#sendService.buildTransaction({ + fromAccountId, + toAddress, + asset, + amount: amountBN, + feeLimit: FEE_LIMIT, + }); const availableEnergy = energyAsset ? new BigNumber(energyAsset.rawAmount) @@ -699,8 +683,7 @@ export class ClientRequestHandler { /** * Show the confirmation UI. - * Client requests come from MetaMask's own unified send flow, so the origin - * is reported as MetaMask. + * Origin is 'MetaMask' because client requests come from MetaMask's own unified send flow. */ const confirmed = await this.#confirmationHandler.confirmTransactionRequest( { @@ -711,7 +694,7 @@ export class ClientRequestHandler { fees, asset, accountType: account.type, - origin: METAMASK_ORIGIN, + origin: 'MetaMask', transactionRawData: freshTransactionRawData, }, ); @@ -777,7 +760,7 @@ export class ClientRequestHandler { * Recreate the transaction object from base64-encoded raw data. * No signing needed - fee calculation uses constant overhead for signature. */ - await this.#accountsService.findByIdOrThrow(accountId); + const account = await this.#accountsService.findByIdOrThrow(accountId); const tronWeb = this.#tronWebFactory.createClient(scope); @@ -802,10 +785,11 @@ export class ClientRequestHandler { }; /** - * Get available Energy and Bandwidth from account assets. + * Get available Energy and Bandwidth from account assets, fetched fresh + * so the fee breakdown reflects the latest account resources. */ const [bandwidthAsset, energyAsset] = - await this.#assetsService.getAccountAssetsByIDs(accountId, [ + await this.#assetsService.getFreshAccountAssetsByIDs(account, [ Networks[scope].bandwidth.id, Networks[scope].energy.id, ]); @@ -860,10 +844,12 @@ export class ClientRequestHandler { const scope = Network.Mainnet; - const asset = await this.#assetsService.getAccountAssetByID( - fromAccountId, - Networks[scope].nativeToken.id, - ); + const [asset, bandwidthAsset, energyAsset] = + await this.#assetsService.getFreshAccountAssetsByIDs(account, [ + Networks[scope].nativeToken.id, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; const requestBalance = BigNumber(value); @@ -887,15 +873,6 @@ export class ClientRequestHandler { account.address, ); - /** - * Get available Energy and Bandwidth from account assets. - */ - const [bandwidthAsset, energyAsset] = - await this.#assetsService.getAccountAssetsByIDs(fromAccountId, [ - Networks[scope].bandwidth.id, - Networks[scope].energy.id, - ]); - const availableEnergy = energyAsset ? BigNumber(energyAsset.rawAmount) : ZERO; @@ -935,9 +912,9 @@ export class ClientRequestHandler { const { accountId, assetId, value } = request.params; - await this.#accountsService.findByIdOrThrow(accountId); - const asset = await this.#assetsService.getAccountAssetByID( - accountId, + const account = await this.#accountsService.findByIdOrThrow(accountId); + const asset = await this.#assetsService.getFreshAccountAssetByID( + account, assetId, ); @@ -983,8 +960,8 @@ export class ClientRequestHandler { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - const asset = await this.#assetsService.getAccountAssetByID( - fromAccountId, + const asset = await this.#assetsService.getFreshAccountAssetByID( + account, assetId, ); @@ -1044,9 +1021,9 @@ export class ClientRequestHandler { */ const stakedAssetId = `${assetId}-staked-for-${purpose.toLowerCase()}`; - await this.#accountsService.findByIdOrThrow(accountId); - const asset = await this.#assetsService.getAccountAssetByID( - accountId, + const account = await this.#accountsService.findByIdOrThrow(accountId); + const asset = await this.#assetsService.getFreshAccountAssetByID( + account, stakedAssetId, ); @@ -1099,8 +1076,8 @@ export class ClientRequestHandler { const account = await this.#accountsService.findByIdOrThrow(accountId); - const asset = await this.#assetsService.getAccountAssetByID( - accountId, + const asset = await this.#assetsService.getFreshAccountAssetByID( + account, stakedAssetId, ); @@ -1153,8 +1130,7 @@ export class ClientRequestHandler { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - const { chainId } = parseCaipAssetType(assetId); - const scope = chainId as Network; + const { chainId: scope } = parseTronCaipAssetType(assetId); const confirmed = await this.#confirmationHandler.confirmClaimUnstakedTrx({ account, @@ -1191,8 +1167,7 @@ export class ClientRequestHandler { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - const { chainId } = parseCaipAssetType(assetId); - const scope = chainId as Network; + const { chainId: scope } = parseTronCaipAssetType(assetId); await this.#stakingService.claimTrxStakingRewards({ account, scope }); diff --git a/packages/tron-wallet-snap/src/handlers/cronjob/cronjob.test.tsx b/packages/tron-wallet-snap/src/handlers/cronjob/cronjob.test.tsx index 366f5ba25..41f538e54 100644 --- a/packages/tron-wallet-snap/src/handlers/cronjob/cronjob.test.tsx +++ b/packages/tron-wallet-snap/src/handlers/cronjob/cronjob.test.tsx @@ -3,6 +3,7 @@ import type { AnalyticsService, IStateManager, } from '@metamask/snap-networks-utils'; +import { InvalidParamsError } from '@metamask/snaps-sdk'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { SnapClient } from '../../clients/snap/SnapClient'; @@ -976,6 +977,51 @@ describe('CronHandler', () => { }); } + + + it('rejects background event params with an unsupported scope', async () => { + await withTrackTransactionCronHandler( + async ({ cronHandler, mockTronHttpClient }) => { + await expect( + cronHandler.handle({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId: TX_ID, + scope: 'eip155:1', + accountIds: ACCOUNT_IDS, + attempt: 0, + }, + }), + ).rejects.toThrow(InvalidParamsError); + expect( + mockTronHttpClient.getTransactionInfoById, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('tracks the transaction through the shared method when params are valid', async () => { + await withTrackTransactionCronHandler( + async ({ cronHandler, mockSnapClient, mockTronHttpClient }) => { + mockTronHttpClient.getTransactionInfoById.mockResolvedValue(null); + + await cronHandler.trackTransaction({ + txId: TX_ID, + scope: Network.Mainnet, + accountIds: ACCOUNT_IDS, + attempt: 0, + }); + + expect( + mockTronHttpClient.getTransactionInfoById, + ).toHaveBeenCalledWith(Network.Mainnet, TX_ID); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); + }, + ); + }); + it('schedules next attempt when transaction is not yet confirmed', async () => { await withTrackTransactionCronHandler( async ({ cronHandler, mockSnapClient, mockTronHttpClient }) => { diff --git a/packages/tron-wallet-snap/src/handlers/cronjob/cronjob.tsx b/packages/tron-wallet-snap/src/handlers/cronjob/cronjob.tsx index 65ab9b813..f21e4c8f4 100644 --- a/packages/tron-wallet-snap/src/handlers/cronjob/cronjob.tsx +++ b/packages/tron-wallet-snap/src/handlers/cronjob/cronjob.tsx @@ -32,6 +32,8 @@ import type { ConfirmSignTransactionContext } from '../../ui/confirmation/views/ import { ConfirmTransactionRequest } from '../../ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest'; import { CONFIRM_TRANSACTION_INTERFACE_NAME } from '../../ui/confirmation/views/ConfirmTransactionRequest/types'; import type { ConfirmTransactionRequestContext } from '../../ui/confirmation/views/ConfirmTransactionRequest/types'; +import { TrackTransactionParamsStruct } from '../../validation/structs'; +import { validateRequest } from '../../validation/validators'; export const CronjobMethod = { ContinuouslySynchronizeSelectedAccounts: @@ -142,15 +144,7 @@ export class CronHandler { await this.refreshSignTransaction(); break; case BackgroundEventMethod.TrackTransaction: - await this.trackTransaction( - params as { - txId: string; - scope: Network; - accountIds: string[]; - attempt: number; - transactionType?: TransactionType; - }, - ); + await this.handleTrackTransaction(request); break; default: throw new Error(`Unknown cronjob method: ${method}`); @@ -654,6 +648,11 @@ export class CronHandler { } } + async handleTrackTransaction(request: JsonRpcRequest): Promise { + validateRequest(request.params, TrackTransactionParamsStruct); + await this.trackTransaction(request.params); + } + /** * Background job to track a transaction's confirmation status. * Continues polling until confirmed, then syncs accounts and emits finalized event. diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts index ba66a5481..8f6db4227 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -4,7 +4,7 @@ import type { KeyringRequest, } from '@metamask/keyring-api'; import type { ExportAccountOptions } from '@metamask/keyring-api/v2'; -import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; import { InvalidParamsError, UserRejectedRequestError, @@ -12,6 +12,7 @@ import { import type { SnapClient } from '../../clients/snap/SnapClient'; import { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; import type { AccountsService } from '../../services/accounts/AccountsService'; import type { AssetsService } from '../../services/assets/AssetsService'; import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; @@ -42,7 +43,7 @@ function toHex(str: string): string { } describe('KeyringHandler', () => { - const mockAccount: ExtendedKeyringAccount = { + const mockAccount: TronKeyringAccount = { id: '123e4567-e89b-42d3-a456-426614174000', address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', options: {}, @@ -94,6 +95,7 @@ describe('KeyringHandler', () => { handleKeyringRequest: jest .fn() .mockResolvedValue({ signature: '0xsignature123' }), + resolveAccountAddress: jest.fn().mockResolvedValue('tron:TB...caip10'), } as unknown as jest.Mocked; mockConfirmationHandler = { handleKeyringRequest: jest.fn().mockResolvedValue(true), @@ -417,7 +419,7 @@ describe('KeyringHandler', () => { describe('multiple accounts', () => { it('handles different accounts correctly', async () => { - const account2: ExtendedKeyringAccount = { + const account2: TronKeyringAccount = { ...mockAccount, id: '987e6543-e89b-42d3-a456-426614174999', address: 'TGehVcNhud84JDCGrNHKVz9jEAVKUpbuiv', @@ -779,4 +781,34 @@ describe('KeyringHandler', () => { ).rejects.toThrow('Error exporting account'); }); }); + + describe('resolveAccountAddress', () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: 'resolveAccountAddress', + params: [], + } as unknown as JsonRpcRequest; + + it('resolves the account address for a supported scope', async () => { + const result = await keyringHandler.resolveAccountAddress( + Network.Mainnet, + request, + ); + + expect(result).toBe('tron:TB...caip10'); + expect(mockWalletService.resolveAccountAddress).toHaveBeenCalledWith( + [mockAccount], + Network.Mainnet, + request, + ); + }); + + it('throws InvalidParamsError for a scope not controlled by the snap', async () => { + await expect( + keyringHandler.resolveAccountAddress('eip155:1', request), + ).rejects.toThrow(InvalidParamsError); + expect(mockWalletService.resolveAccountAddress).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts index 01507b548..cf5a8a1f2 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts @@ -19,10 +19,7 @@ import { asStrictKeyringAccount, validateOrigin, } from '@metamask/snap-networks-utils'; -import type { - ExtendedKeyringAccount, - Logger, -} from '@metamask/snap-networks-utils'; +import type { Logger } from '@metamask/snap-networks-utils'; import { InvalidParamsError, SnapError, @@ -39,7 +36,7 @@ import { sortBy } from 'lodash'; import type { SnapClient } from '../../clients/snap/SnapClient'; import { ESSENTIAL_ASSETS } from '../../constants'; -import type { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; import { originPermissions } from '../../permissions'; import type { AccountsService } from '../../services/accounts/AccountsService'; import type { AssetsService } from '../../services/assets/AssetsService'; @@ -56,6 +53,7 @@ import { ListAccountAssetsStruct, ListAccountTransactionsStruct, PrivateKeyHexStruct, + ResolveAccountAddressStruct, SignTransactionRequestStruct, TronKeyringRequestStruct, } from '../../validation/structs'; @@ -111,7 +109,7 @@ export class KeyringHandler implements KeyringSnapRpc { return result ?? null; } - async #listAccounts(): Promise { + async #listAccounts(): Promise { try { const keyringAccounts = await this.#accountsService.getAll(); @@ -133,7 +131,7 @@ export class KeyringHandler implements KeyringSnapRpc { async #getAccount( accountId: string, - ): Promise { + ): Promise { try { const account = (await this.#accountsService.findById(accountId)) ?? undefined; @@ -158,7 +156,7 @@ export class KeyringHandler implements KeyringSnapRpc { } } - async #getAccountOrThrow(accountId: string): Promise { + async #getAccountOrThrow(accountId: string): Promise { const account = await this.#getAccount(accountId); if (!account) { @@ -321,6 +319,9 @@ export class KeyringHandler implements KeyringSnapRpc { scope: CaipChainId, request: JsonRpcRequest, ): Promise { + const params = { scope }; + validateRequest(params, ResolveAccountAddressStruct); + this.#logger.info('Resolving account address', { scope, request }); // Get all keyring accounts @@ -329,7 +330,7 @@ export class KeyringHandler implements KeyringSnapRpc { // Resolve the address using the wallet service const caip10Address = await this.#walletService.resolveAccountAddress( keyringAccounts, - scope as Network, + params.scope, request, ); diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsRepository.test.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsRepository.test.ts index 96a580367..ba49d747f 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsRepository.test.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsRepository.test.ts @@ -1,7 +1,7 @@ import { TrxAccountType, TrxScope } from '@metamask/keyring-api'; import { InMemoryState } from '@metamask/snap-networks-utils'; -import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; import type { UnencryptedStateValue } from '../state/stateTypes'; import { AccountsRepository } from './AccountsRepository'; @@ -12,7 +12,7 @@ import { AccountsRepository } from './AccountsRepository'; * @returns An in-memory state seeded with the provided accounts. */ function createEmptyState( - keyringAccounts: Record = {}, + keyringAccounts: Record = {}, ): InMemoryState { return new InMemoryState({ keyringAccounts, @@ -29,8 +29,8 @@ function createEmptyState( * @returns A complete Tron keyring account for tests. */ function createTestAccount( - overrides: Partial = {}, -): ExtendedKeyringAccount { + overrides: Partial = {}, +): TronKeyringAccount { return { id: 'account-0', entropySource: 'test-entropy', diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts index fb5b386ff..45a26a253 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts @@ -1,9 +1,7 @@ import type { EntropySourceId } from '@metamask/keyring-api'; -import type { - ExtendedKeyringAccount, - IStateManager, -} from '@metamask/snap-networks-utils'; +import type { IStateManager } from '@metamask/snap-networks-utils'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; import type { UnencryptedStateValue } from '../state/stateTypes'; /** @@ -17,7 +15,7 @@ type AccountCreationRange = { to: number; }; -type KeyringAccountsState = Record; +type KeyringAccountsState = Record; /** * Result of merging accounts into `keyringAccounts`. @@ -27,8 +25,8 @@ type KeyringAccountsState = Record; * conflict losers are omitted (their winners are present in `merged`). */ export type KeyringAccountsMergeResult = { - merged: Record; - added: Record; + merged: Record; + added: Record; }; /** @@ -37,7 +35,7 @@ export type KeyringAccountsMergeResult = { * @param account - The account to key. * @returns A stable conflict key for the account. */ -function getAccountIndexKey(account: ExtendedKeyringAccount): string { +function getAccountIndexKey(account: TronKeyringAccount): string { return `${account.entropySource}:${account.index}`; } @@ -51,7 +49,7 @@ function getAccountIndexKey(account: ExtendedKeyringAccount): string { function findAccountByIndexKey( accounts: KeyringAccountsState, indexKey: string, -): ExtendedKeyringAccount | undefined { +): TronKeyringAccount | undefined { return Object.values(accounts).find( (account) => getAccountIndexKey(account) === indexKey, ); @@ -104,7 +102,7 @@ export class AccountsRepository { * * @returns All accounts from the state. */ - async getAll(): Promise { + async getAll(): Promise { const accounts = await this.#state.getKey< UnencryptedStateValue['keyringAccounts'] >(this.#storageKey); @@ -122,7 +120,7 @@ export class AccountsRepository { async findByEntropySourceAndRange( entropySource: EntropySourceId, range: AccountCreationRange, - ): Promise { + ): Promise { const accounts = await this.getAll(); return accounts @@ -135,7 +133,7 @@ export class AccountsRepository { .sort((first, second) => first.index - second.index); } - async findById(id: string): Promise { + async findById(id: string): Promise { const accounts = await this.getAll(); return accounts.find((account) => account.id === id) ?? null; } @@ -147,13 +145,13 @@ export class AccountsRepository { * @returns The matching accounts. Result ordering follows stored account * ordering, not input ordering. */ - async findByIds(ids: string[]): Promise { + async findByIds(ids: string[]): Promise { const accounts = await this.getAll(); const normalizedIds = new Set(); ids.forEach((id) => normalizedIds.add(id.toLowerCase())); - const matchedAccounts: ExtendedKeyringAccount[] = []; + const matchedAccounts: TronKeyringAccount[] = []; accounts.forEach((account) => { if (normalizedIds.has(account.id.toLowerCase())) { matchedAccounts.push(account); @@ -163,15 +161,13 @@ export class AccountsRepository { return matchedAccounts; } - async findByAddress(address: string): Promise { + async findByAddress(address: string): Promise { const accounts = await this.getAll(); return accounts.find((account) => account.address === address) ?? null; } - async create( - account: ExtendedKeyringAccount, - ): Promise { + async create(account: TronKeyringAccount): Promise { let persistedAccount = account; await this.#state.setKeyWith( @@ -204,7 +200,7 @@ export class AccountsRepository { * without re-reading state. */ async mergeKeyringAccounts( - newAccounts: Record, + newAccounts: Record, ): Promise { let result: KeyringAccountsMergeResult = { merged: {}, added: {} }; diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts index 8c7b4c15f..f53a68f1e 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts @@ -10,15 +10,13 @@ import type { } from '@metamask/keyring-api'; import { AccountCreationType, TrxAccountType } from '@metamask/keyring-api'; import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; -import type { - ExtendedKeyringAccount, - Logger, -} from '@metamask/snap-networks-utils'; +import type { Logger } from '@metamask/snap-networks-utils'; import { LogLevel } from '@metamask/snap-networks-utils'; import type { SnapClient } from '../../clients/snap/SnapClient'; import { Network } from '../../constants'; import type { NativeAsset } from '../../entities/assets'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; import { createTronBip44KeypairDeriver } from '../../utils/deriveTronFromCoinTypeNode'; import { trackError } from '../../utils/errors'; import { mockLogger } from '../../utils/mockLogger'; @@ -141,9 +139,9 @@ async function withAccountsService( configurable: true, }); - const keyringAccounts: ExtendedKeyringAccount[] = []; + const keyringAccounts: TronKeyringAccount[] = []; - const getAccountIndexKey = (account: ExtendedKeyringAccount): string => + const getAccountIndexKey = (account: TronKeyringAccount): string => `${account.entropySource}:${account.index}`; const mockAccountsRepository: jest.Mocked< @@ -187,7 +185,7 @@ async function withAccountsService( ), create: jest .fn() - .mockImplementation(async (account: ExtendedKeyringAccount) => { + .mockImplementation(async (account: TronKeyringAccount) => { const conflicting = keyringAccounts.find( (existing) => getAccountIndexKey(existing) === getAccountIndexKey(account), @@ -203,9 +201,9 @@ async function withAccountsService( mergeKeyringAccounts: jest .fn() .mockImplementation( - async (newAccounts: Record) => { + async (newAccounts: Record) => { const occupied = new Set(keyringAccounts.map(getAccountIndexKey)); - const added: Record = {}; + const added: Record = {}; for (const [id, account] of Object.entries(newAccounts)) { const indexKey = getAccountIndexKey(account); @@ -339,7 +337,7 @@ describe('AccountsService', () => { }); describe('deriveTronKeypairs', () => { - const createAccount = (index: number): ExtendedKeyringAccount => + const createAccount = (index: number): TronKeyringAccount => ({ id: `account-${index}`, entropySource: 'test-entropy', @@ -347,10 +345,10 @@ describe('AccountsService', () => { index, address: `TAccount${index}`, type: TrxAccountType.Eoa, - scopes: SUPPORTED_SCOPES as unknown as Network[], + scopes: [...SUPPORTED_SCOPES], options: {}, methods: ['signMessage', 'signTransaction'], - }) as unknown as ExtendedKeyringAccount; + }) as unknown as TronKeyringAccount; it('derives multiple keypairs with one entropy fetch per entropy source', async () => { const coinJson = await getTronTestCoinTypeJson(); @@ -444,7 +442,7 @@ describe('AccountsService', () => { mockAccountsRepository.mergeKeyringAccounts, ).toHaveBeenCalledTimes(1); const merged = mockAccountsRepository.mergeKeyringAccounts.mock - .calls[0]?.[0] as Record; + .calls[0]?.[0] as Record; expect(Object.keys(merged)).toHaveLength(2); }, coinJson, @@ -476,7 +474,7 @@ describe('AccountsService', () => { ).toHaveBeenCalledTimes(1); const mergedAccounts = mockAccountsRepository.mergeKeyringAccounts - .mock.calls[0]?.[0] as Record; + .mock.calls[0]?.[0] as Record; expect(Object.keys(mergedAccounts)).toHaveLength(101); expect( @@ -491,14 +489,14 @@ describe('AccountsService', () => { it('returns persisted accounts when merge skips indices taken concurrently', async () => { const coinJson = await getTronTestCoinTypeJson(); - const concurrentAccount: ExtendedKeyringAccount = { + const concurrentAccount: TronKeyringAccount = { id: 'concurrent-0', entropySource: 'test-entropy', derivationPath: "m/44'/195'/0'/0/0", index: 0, type: TrxAccountType.Eoa, address: 'TConcurrent0', - scopes: SUPPORTED_SCOPES as unknown as Network[], + scopes: [...SUPPORTED_SCOPES], options: {}, methods: ['signMessage', 'signTransaction'], }; @@ -541,25 +539,25 @@ describe('AccountsService', () => { it('does not merge when accounts already exist for the range', async () => { const coinJson = await getTronTestCoinTypeJson(); - const existing0: ExtendedKeyringAccount = { + const existing0: TronKeyringAccount = { id: 'existing-0', entropySource: 'test-entropy', derivationPath: "m/44'/195'/0'/0/0", index: 0, type: TrxAccountType.Eoa, address: 'TExisting0', - scopes: SUPPORTED_SCOPES as unknown as Network[], + scopes: [...SUPPORTED_SCOPES], options: {}, methods: ['signMessage', 'signTransaction'], }; - const existing1: ExtendedKeyringAccount = { + const existing1: TronKeyringAccount = { id: 'existing-1', entropySource: 'test-entropy', derivationPath: "m/44'/195'/0'/0/1", index: 1, type: TrxAccountType.Eoa, address: 'TExisting1', - scopes: SUPPORTED_SCOPES as unknown as Network[], + scopes: [...SUPPORTED_SCOPES], options: {}, methods: ['signMessage', 'signTransaction'], }; @@ -784,7 +782,7 @@ describe('AccountsService', () => { describe('getAll', () => { it('delegates to repository and returns result', async () => { - const accounts: ExtendedKeyringAccount[] = [ + const accounts: TronKeyringAccount[] = [ { id: 'a1', address: 'TAddr1', @@ -813,7 +811,7 @@ describe('AccountsService', () => { describe('getAllSelected', () => { it('returns only accounts whose IDs are in getSelectedAccounts', async () => { - const account1: ExtendedKeyringAccount = { + const account1: TronKeyringAccount = { id: 'selected-1', address: 'TAddr1', type: TrxAccountType.Eoa, @@ -824,7 +822,7 @@ describe('AccountsService', () => { derivationPath: "m/44'/195'/0'/0/0", index: 0, }; - const account2: ExtendedKeyringAccount = { + const account2: TronKeyringAccount = { id: 'not-selected', address: 'TAddr2', type: TrxAccountType.Eoa, @@ -865,7 +863,7 @@ describe('AccountsService', () => { describe('findById', () => { it('delegates to repository', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'find-id', address: 'TFind', type: TrxAccountType.Eoa, @@ -894,7 +892,7 @@ describe('AccountsService', () => { describe('findByIdOrThrow', () => { it('returns account when found', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'throw-found', address: 'TFound', type: TrxAccountType.Eoa, @@ -932,7 +930,7 @@ describe('AccountsService', () => { describe('findByIds', () => { it('returns accounts from repository', async () => { - const accounts: ExtendedKeyringAccount[] = [ + const accounts: TronKeyringAccount[] = [ { id: 'id1', address: 'T1', @@ -978,7 +976,7 @@ describe('AccountsService', () => { describe('findByAddress', () => { it('delegates to repository', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'addr-id', address: 'TByAddress123456789012345678', type: TrxAccountType.Eoa, @@ -1023,7 +1021,7 @@ describe('AccountsService', () => { describe('synchronize (assets)', () => { it('fetches assets for each account and scope, then saves', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'sync-asset-id', address: 'TSyncAsset12345678901234567', type: TrxAccountType.Eoa, @@ -1077,7 +1075,7 @@ describe('AccountsService', () => { }); it('tracks save failures standalone, without failing', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'sync-asset-fail-id', address: 'TSyncFail123456789012345678', type: TrxAccountType.Eoa, @@ -1113,7 +1111,7 @@ describe('AccountsService', () => { }); it('points at the specific account whose fetch failed', async () => { - const failingAccount: ExtendedKeyringAccount = { + const failingAccount: TronKeyringAccount = { id: 'sync-fail-id', address: 'TSyncFail123456789012345678', type: TrxAccountType.Eoa, @@ -1124,7 +1122,7 @@ describe('AccountsService', () => { derivationPath: "m/44'/195'/0'/0/0", index: 0, }; - const healthyAccount: ExtendedKeyringAccount = { + const healthyAccount: TronKeyringAccount = { id: 'sync-healthy-id', address: 'TSyncHealthy123456789012345', type: TrxAccountType.Eoa, @@ -1176,7 +1174,7 @@ describe('AccountsService', () => { }); it('survives a hostile rejection reason that cannot be stringified', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'sync-hostile-id', address: 'TSyncHostile12345678901234567', type: TrxAccountType.Eoa, @@ -1223,7 +1221,7 @@ describe('AccountsService', () => { async ({ accountsService, mockConfigProvider, mockAssetsService }) => { mockConfigProvider.config = MOCK_CONFIG; - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'empty-id', address: 'TEmpty12345678901234567890', type: TrxAccountType.Eoa, @@ -1248,7 +1246,7 @@ describe('AccountsService', () => { describe('synchronizeTransactions', () => { it('calls fetch for each account and scope, then saveMany', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'sync-tx-id', address: 'TSyncTx123456789012345678', type: TrxAccountType.Eoa, @@ -1302,7 +1300,7 @@ describe('AccountsService', () => { }); it('tracks the failing account when a fetch fails, without failing', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'sync-tx-fail-id', address: 'TSyncTxFail12345678901234567', type: TrxAccountType.Eoa, @@ -1346,7 +1344,7 @@ describe('AccountsService', () => { }); it('tracks save failures standalone and propagates them to synchronize', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'sync-tx-save-fail-id', address: 'TSyncTxSaveFail12345678901234', type: TrxAccountType.Eoa, @@ -1390,7 +1388,7 @@ describe('AccountsService', () => { describe('synchronize', () => { it('fetches both assets and transactions', async () => { - const account: ExtendedKeyringAccount = { + const account: TronKeyringAccount = { id: 'sync-id', address: 'TSync12345678901234567890', type: TrxAccountType.Eoa, @@ -1429,7 +1427,7 @@ describe('AccountsService', () => { const makeSyncAccount = ( id: string, index: number, - ): ExtendedKeyringAccount => ({ + ): TronKeyringAccount => ({ id, address: `TCoalesce${index}2345678901234567890`, type: TrxAccountType.Eoa, diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index d5bcfe83a..8cb39ccb5 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -16,10 +16,7 @@ import { asStrictKeyringAccount, getSyncFailuresFromSettledResult, } from '@metamask/snap-networks-utils'; -import type { - ExtendedKeyringAccount, - Logger, -} from '@metamask/snap-networks-utils'; +import type { Logger } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/superstruct'; import { hexToBytes } from '@metamask/utils'; import { computeAddress } from 'ethers'; @@ -27,7 +24,9 @@ import { TronWeb } from 'tronweb'; import snapManifest from '../../../snap.manifest.json'; import type { SnapClient } from '../../clients/snap/SnapClient'; -import { Network } from '../../constants'; +import { assertSupportedNetwork } from '../../constants'; +import type { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; import type { DerivedTronKeypair } from '../../utils/deriveTronFromCoinTypeNode'; import { createTronBip44AddressDeriver, @@ -50,9 +49,10 @@ const CURVE = 'secp256k1' as const; */ const MAX_BIP44_ACCOUNT_INDEX = 0x7fffffff; -export const SUPPORTED_SCOPES = snapManifest.initialPermissions[ - 'endowment:keyring' -].capabilities.scopes as readonly Network[]; +export const SUPPORTED_SCOPES: readonly Network[] = + snapManifest.initialPermissions['endowment:keyring'].capabilities.scopes.map( + assertSupportedNetwork, + ); /** * Range of inclusive account indices to create. @@ -238,12 +238,12 @@ export class AccountsService { * @returns One derivation result per account, in input order. */ async deriveTronKeypairs( - accounts: ExtendedKeyringAccount[], + accounts: TronKeyringAccount[], ): Promise { const results: DerivedTronKeypairBatchResult[] = new Array(accounts.length); const accountsByEntropySource = new Map< EntropySourceId, - { index: number; account: ExtendedKeyringAccount }[] + { index: number; account: TronKeyringAccount }[] >(); accounts.forEach((account, index) => { @@ -345,7 +345,7 @@ export class AccountsService { ]); const readAndEntropyMs = Date.now() - startMs; - const allAccounts = new Map(); + const allAccounts = new Map(); for (const account of existingAccounts) { allAccounts.set(account.index, account); } @@ -357,7 +357,7 @@ export class AccountsService { } } - const newAccounts: Record = {}; + const newAccounts: Record = {}; let created = 0; let deriveMs = 0; let mergeMs = 0; @@ -371,14 +371,14 @@ export class AccountsService { AccountsService.getDefaultDerivationPath(groupIndex); const { address } = await tronAddressDeriver(groupIndex); - const tronKeyringAccount: ExtendedKeyringAccount = { + const tronKeyringAccount: TronKeyringAccount = { id, entropySource, derivationPath, index: groupIndex, type: TrxAccountType.Eoa, address, - scopes: SUPPORTED_SCOPES as unknown as Network[], + scopes: [...SUPPORTED_SCOPES], options: { entropy: { type: 'mnemonic', @@ -441,11 +441,11 @@ export class AccountsService { return result; } - async getAll(): Promise { + async getAll(): Promise { return this.#accountsRepository.getAll(); } - async getAllSelected(): Promise { + async getAllSelected(): Promise { const [allAccounts, selectedAccountIds] = await Promise.all([ this.#accountsRepository.getAll(), getSelectedAccounts(snap), @@ -456,7 +456,7 @@ export class AccountsService { ); } - async findById(id: string): Promise { + async findById(id: string): Promise { return this.#accountsRepository.findById(id); } @@ -468,7 +468,7 @@ export class AccountsService { * @returns The account if found. * @throws {Error} If the account is not found. */ - async findByIdOrThrow(id: string): Promise { + async findByIdOrThrow(id: string): Promise { const account = await this.#accountsRepository.findById(id); if (!account) { @@ -487,7 +487,7 @@ export class AccountsService { * @param ids - Account IDs to resolve. * @returns The matching accounts. */ - async findByIds(ids: string[]): Promise { + async findByIds(ids: string[]): Promise { const accounts = await this.#accountsRepository.findByIds(ids); if (ids.length !== accounts.length) { @@ -497,7 +497,7 @@ export class AccountsService { return accounts; } - async findByAddress(address: string): Promise { + async findByAddress(address: string): Promise { return this.#accountsRepository.findByAddress(address); } @@ -514,7 +514,7 @@ export class AccountsService { * * @param accounts - The accounts to synchronize assets for. */ - async synchronizeAssets(accounts: ExtendedKeyringAccount[]): Promise { + async synchronizeAssets(accounts: TronKeyringAccount[]): Promise { const scopes = this.#configProvider.config.activeNetworks; const combinations = accounts.flatMap((account) => scopes.map((scope) => ({ account, scope })), @@ -558,9 +558,7 @@ export class AccountsService { * * @param accounts - The accounts to synchronize transactions for. */ - async synchronizeTransactions( - accounts: ExtendedKeyringAccount[], - ): Promise { + async synchronizeTransactions(accounts: TronKeyringAccount[]): Promise { const scopes = this.#configProvider.config.activeNetworks; const combinations = accounts.flatMap((account) => scopes.map((scope) => ({ account, scope })), @@ -604,7 +602,7 @@ export class AccountsService { * * @param accounts - The accounts to synchronize. */ - async synchronize(accounts: ExtendedKeyringAccount[]): Promise { + async synchronize(accounts: TronKeyringAccount[]): Promise { // Sync triggers stack up (60s cronjob, a background event scheduled by // every `setSelectedAccounts` call, post-transaction refreshes), so // concurrent invocations for the same accounts share one run instead of diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 181a0555e..dc6a30d69 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -8,6 +8,7 @@ import type { KeyringAccount } from '@metamask/keyring-api'; import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import type { Network } from '../../constants'; +import { parseTronCaipAssetType } from '../../utils/caip'; import type { AssetEntity } from '../../entities/assets'; import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; @@ -57,6 +58,10 @@ export class AssetsService { return []; } + assetIds.forEach((assetId) => { + parseTronCaipAssetType(assetId); + }); + if (await this.#shouldReturnAssetsFromCore()) { const assets = await this.#coreAdapter.getAccountAssetsByIDs( accountId, @@ -72,6 +77,8 @@ export class AssetsService { accountId: string, assetId: string, ): Promise { + parseTronCaipAssetType(assetId); + if (await this.#shouldReturnAssetsFromCore()) { const asset = await this.#coreAdapter.getAccountAssetByID( accountId, @@ -109,4 +116,66 @@ export class AssetsService { return this.#snapAdapter.getAccountAssets(accountId); } + + /** + * Fetches fresh (up-to-date) assets for the requested asset IDs, for flows + * that must not act on stale balances. + * + * When the assets migration is active, the Core adapter combines the + * controller's one-time fetch with a direct Tron RPC sync of snap-owned + * assets. Otherwise, the Snap's own sync flow refreshes and persists the + * latest values before reading them. + * + * @param account - The keyring account to fetch assets for. + * @param assetIds - CAIP-19 asset IDs to resolve fresh values for. + * @returns Assets in the same order as the requested asset IDs, with `null` + * for asset IDs that could not be resolved. + */ + async getFreshAccountAssetsByIDs( + account: KeyringAccount, + assetIds: string[], + ): Promise<(AssetEntity | null)[]> { + if (assetIds.length === 0) { + return []; + } + + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getFreshAccountAssetsByIDs( + account, + assetIds as Caip19AssetId[], + ); + } + + const scopes = [ + ...new Set( + assetIds.map((assetId) => parseTronCaipAssetType(assetId).chainId), + ), + ] as Network[]; + + const freshAssets = ( + await Promise.all( + scopes.map((scope) => + this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account), + ), + ) + ).flat(); + await this.#snapAdapter.saveMany(freshAssets); + + return this.#snapAdapter.getAccountAssetsByIDs(account.id, assetIds); + } + + /** + * Fetches a single fresh asset for the given asset ID. + * + * @param account - The keyring account to fetch the asset for. + * @param assetId - CAIP-19 asset ID to resolve a fresh value for. + * @returns The fresh asset, or `null` if it could not be resolved. + */ + async getFreshAccountAssetByID( + account: KeyringAccount, + assetId: string, + ): Promise { + const [asset] = await this.getFreshAccountAssetsByIDs(account, [assetId]); + return asset ?? null; + } } diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts index 5a2382909..bdc1e6952 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts @@ -5,9 +5,13 @@ import type { AccountBalancesUpdatedEvent, KeyringAccount, } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { AssetsProvider } from '@metamask/snap-networks-utils'; import { Logger } from '@metamask/snap-networks-utils'; +import type { CaipChainId } from '@metamask/utils'; + +import { parseTronCaipAssetType } from '../../../utils/caip'; import type { TronHttpClient } from '../../../clients/tron-http/TronHttpClient'; import { TrongridAccountNotFoundError } from '../../../clients/trongrid/errors'; @@ -29,11 +33,39 @@ export type CoreAssetsAdapterOptions = { getAccountAssetByID: AssetsProvider['getAccountAssetByID']; getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; + getAssets: AssetsProvider['getAssets']; getAddressInfo: TrongridApiClient['getAccountInfoByAddress']; getAddressResources: TronHttpClient['getAccountResources']; getAddressStakingRewards: TronHttpClient['getReward']; }; +/** + * Builds the account descriptor the controller's one-time fetch pipeline + * expects. The controller only consumes `id`, `address`, and `scopes` from it, + * so the keyring account's shape is sufficient and the metadata is a stub. + * + * @param account - The keyring account. + * @param scopes - The CAIP-2 chain IDs to scope the fetch to. + * @returns An InternalAccount-compatible descriptor. + */ +function toInternalAccount( + account: KeyringAccount, + scopes: CaipChainId[], +): InternalAccount { + return { + id: account.id, + address: account.address, + type: account.type, + scopes, + methods: account.methods, + options: account.options, + metadata: { + name: 'Tron Account', + keyring: { type: 'Snap Keyring' }, + }, + } as unknown as InternalAccount; +} + /** * Uses the AssetsController for fungible reads. Snap-owned (special) assets are * published via keyring events without local persistence when migration is active. @@ -47,6 +79,8 @@ export class CoreAssetsAdapter { readonly #getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; + readonly #getAssets: AssetsProvider['getAssets']; + readonly #getAddressInfo: TrongridApiClient['getAccountInfoByAddress']; readonly #getAddressResources: TronHttpClient['getAccountResources']; @@ -58,6 +92,7 @@ export class CoreAssetsAdapter { getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, + getAssets, getAddressInfo, getAddressResources, getAddressStakingRewards, @@ -67,6 +102,7 @@ export class CoreAssetsAdapter { this.#getAccountAssetByID = getAccountAssetByID; this.#getAccountAssetsByIDs = getAccountAssetsByIDs; this.#getAccountAssetsByScope = getAccountAssetsByScope; + this.#getAssets = getAssets; this.#getAddressInfo = getAddressInfo; this.#getAddressResources = getAddressResources; this.#getAddressStakingRewards = getAddressStakingRewards; @@ -136,6 +172,74 @@ export class CoreAssetsAdapter { return allAssets; } + /** + * Fetches fresh assets for the requested asset IDs, guaranteeing up-to-date + * data for flows that act on-chain or display actionable values. + * + * Combines both freshness strategies: + * - Controller-tracked assets (native TRX, TRC20) come from the controller's + * one-time fetch pipeline (`AssetsController:getAssets` with + * `forceUpdate` and `bypassServerCache`), reading the fresh fetch result + * directly. + * - Snap-owned assets (staking positions, energy, bandwidth) are fetched + * directly from Tron RPC via the Snap's own sync flow, since the + * controller only sees them through asynchronously published updates. + * + * @param account - The keyring account to fetch assets for. + * @param assetIds - CAIP-19 asset IDs to resolve fresh values for. + * @returns Assets keyed in the same order as the requested asset IDs, with + * `null` for asset IDs that could not be resolved. + */ + async getFreshAccountAssetsByIDs( + account: KeyringAccount, + assetIds: Caip19AssetId[], + ): Promise<(AssetEntity | null)[]> { + const scopes = Array.from( + new Set(assetIds.map((assetId) => parseTronCaipAssetType(assetId).chainId)), + ) as Network[]; + + const [snapOwnedFetches, controllerAssets] = await Promise.all([ + Promise.all( + scopes.map((scope) => + this.fetchAssetsAndBalancesForAccount(scope as Network, account), + ), + ), + this.#getAssets([toInternalAccount(account, scopes)], { + chainIds: scopes, + forceUpdate: true, + bypassServerCache: true, + }), + ]); + + const snapOwnedAssets = snapOwnedFetches.flat(); + + return assetIds.map((assetId) => { + if (isSnapOwnedAsset(assetId)) { + return ( + snapOwnedAssets.find((asset) => asset.assetType === assetId) ?? null + ); + } + + const asset = controllerAssets[account.id]?.[assetId]; + return asset ? mapControllerAsset(account.id, asset) : null; + }); + } + + /** + * Fetches a single fresh asset for the given asset ID. + * + * @param account - The keyring account to fetch the asset for. + * @param assetId - CAIP-19 asset ID to resolve a fresh value for. + * @returns The fresh asset, or `null` if it could not be resolved. + */ + async getFreshAccountAssetByID( + account: KeyringAccount, + assetId: Caip19AssetId, + ): Promise { + const [asset] = await this.getFreshAccountAssetsByIDs(account, [assetId]); + return asset ?? null; + } + /** * We used to fetch all assets and balances but now the Snap is only responsible for fetching snap-owned assets. * - Energy & Bandwidth diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts index 56757ebc3..25b71768b 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -8,7 +8,7 @@ import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { IStateManager, Logger } from '@metamask/snap-networks-utils'; import type { AssetMetadata, FungibleAssetMetadata } from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; +import { parseTronCaipAssetType } from '../../../utils/caip'; import type { PriceApiClient } from '../../../clients/price-api/PriceApiClient'; import type { SpotPrices } from '../../../clients/price-api/types'; @@ -1118,12 +1118,12 @@ export class SnapAssetsAdapter { keyringAccountId: string, ): AssetEntity { const metadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; - const { chainId } = parseCaipAssetType(assetId); + const { chainId: network } = parseTronCaipAssetType(assetId); return { assetType: metadata.id, keyringAccountId, - network: chainId as Network, + network, symbol: metadata.symbol, decimals: metadata.decimals, rawAmount: '0', diff --git a/packages/tron-wallet-snap/src/services/assets/types.ts b/packages/tron-wallet-snap/src/services/assets/types.ts index d03b20a4e..ebbe158dc 100644 --- a/packages/tron-wallet-snap/src/services/assets/types.ts +++ b/packages/tron-wallet-snap/src/services/assets/types.ts @@ -1,5 +1,4 @@ import { CaipAssetTypeStruct } from '@metamask/keyring-api'; -import type { TrxScope } from '@metamask/keyring-api'; import { pattern } from '@metamask/superstruct'; import type { TronAccount } from '../../clients/trongrid/types'; @@ -12,17 +11,19 @@ export type StakedData = { }; export type NativeCaipAssetType = `${Network}/slip44:195`; -export type StakedCaipAssetType = - `${TrxScope}/slip44:195-staked-for-${'energy' | 'bandwidth'}`; +export type StakedCaipAssetType = `${Network}/slip44:195-staked-for-${ + | 'energy' + | 'bandwidth'}`; export type ReadyForWithdrawalCaipAssetType = - `${TrxScope}/slip44:195-ready-for-withdrawal`; + `${Network}/slip44:195-ready-for-withdrawal`; export type StakingRewardsCaipAssetType = - `${TrxScope}/slip44:195-staking-rewards`; -export type InLockPeriodCaipAssetType = `${TrxScope}/slip44:195-in-lock-period`; -export type ResourceCaipAssetType = - `${TrxScope}/slip44:${'energy' | 'bandwidth'}`; -export type TokenCaipAssetType = `${TrxScope}/${'trc10' | 'trc20'}:${string}`; -export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; + `${Network}/slip44:195-staking-rewards`; +export type InLockPeriodCaipAssetType = `${Network}/slip44:195-in-lock-period`; +export type ResourceCaipAssetType = `${Network}/slip44:${ + | 'energy' + | 'bandwidth'}`; +export type TokenCaipAssetType = `${Network}/${'trc10' | 'trc20'}:${string}`; +export type NftCaipAssetType = `${Network}/trc721:${string}`; /** * Validates a TRON native CAIP-19 ID (e.g., "tron:728126428/slip44:195") diff --git a/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.test.ts b/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.test.ts index 7e28a2656..f0b5ff721 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.test.ts @@ -58,4 +58,19 @@ describe('mapControllerAsset', () => { iconUrl: '', }); }); + + it('throws when the asset is not on a supported network', () => { + const asset = { + id: 'eip155:1/slip44:60', + chainId: 'eip155:1', + balance: { amount: '1' }, + metadata: { type: 'native', symbol: 'ETH', name: 'Ether', decimals: 18 }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(() => mapControllerAsset(accountId, asset)).toThrow( + 'Unsupported network: "eip155:1"', + ); + }); }); diff --git a/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.ts index d55786adb..ddc84c491 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.ts @@ -1,6 +1,6 @@ import type { Asset } from '@metamask/assets-controller'; -import { Network } from '../../../constants'; +import type { Network } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import { toRawAmount } from '../../../utils/conversion'; diff --git a/packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index d78e5dd8d..f8ea6fb07 100644 --- a/packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -12,7 +12,7 @@ import { Duration } from '@metamask/utils'; import { Network } from '../../constants'; -const NetworkStruct = enums(Object.values(Network) as [Network, ...Network[]]); +const NetworkStruct = enums(Object.values(Network)); /** * The default base URLs for the price, token, and static APIs, used when diff --git a/packages/tron-wallet-snap/src/services/send/SendService.ts b/packages/tron-wallet-snap/src/services/send/SendService.ts index e8c5eb8c8..c53bb8a4e 100644 --- a/packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/packages/tron-wallet-snap/src/services/send/SendService.ts @@ -1,17 +1,12 @@ import type { AnalyticsService, Logger } from '@metamask/snap-networks-utils'; -import { parseCaipAssetType } from '@metamask/utils'; +import { parseTronCaipAssetType } from '../../utils/caip'; import { BigNumber } from 'bignumber.js'; import type { TronWeb, Types as TronwebTypes } from 'tronweb'; import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import type { Network } from '../../constants'; -import { - METAMASK_ORIGIN, - Networks, - TRACK_TX_INTERVAL, - ZERO, -} from '../../constants'; +import { Networks, TRACK_TX_INTERVAL, ZERO } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; import { SendErrorCodes } from '../../handlers/clientRequest/types'; import { BackgroundEventMethod } from '../../handlers/cronjob/cronjob'; @@ -233,16 +228,15 @@ export class SendService { | TronwebTypes.Transaction | TronwebTypes.Transaction > { - const { chainId, assetNamespace, assetReference } = parseCaipAssetType( - asset.assetType, - ); + const { chainId: scope, assetNamespace, assetReference } = + parseTronCaipAssetType(asset.assetType); try { switch (assetNamespace) { case 'slip44': this.#logger.log('Sending TRX transaction'); return this.buildSendTrxTransaction({ - scope: chainId as Network, + scope, fromAccountId, toAddress, amount, @@ -252,7 +246,7 @@ export class SendService { case 'trc10': this.#logger.log(`Sending TRC10 token: ${assetReference}`); return this.buildSendTrc10Transaction({ - scope: chainId as Network, + scope, fromAccountId, toAddress, amount, @@ -264,7 +258,7 @@ export class SendService { case 'trc20': this.#logger.log(`Sending TRC20 token: ${assetReference}`); return this.buildSendTrc20Transaction({ - scope: chainId as Network, + scope, fromAccountId, toAddress, contractAddress: assetReference, @@ -388,7 +382,7 @@ export class SendService { scope, fromAccountId, transaction, - origin = METAMASK_ORIGIN, + origin = 'MetaMask', }: { scope: Network; fromAccountId: string; diff --git a/packages/tron-wallet-snap/src/services/staking/StakingService.test.ts b/packages/tron-wallet-snap/src/services/staking/StakingService.test.ts index 5086ebfd3..6ea1a7254 100644 --- a/packages/tron-wallet-snap/src/services/staking/StakingService.test.ts +++ b/packages/tron-wallet-snap/src/services/staking/StakingService.test.ts @@ -452,7 +452,7 @@ describe('StakingService', () => { assetId: invalidAssetId, amount, }), - ).rejects.toThrow('Invalid asset ID'); + ).rejects.toThrow('Expected the value to satisfy a union'); }); it('correctly derives keypair for unstaking', async () => { diff --git a/packages/tron-wallet-snap/src/services/staking/StakingService.ts b/packages/tron-wallet-snap/src/services/staking/StakingService.ts index 8281308e4..0927f7fda 100644 --- a/packages/tron-wallet-snap/src/services/staking/StakingService.ts +++ b/packages/tron-wallet-snap/src/services/staking/StakingService.ts @@ -2,7 +2,7 @@ import type { ExtendedKeyringAccount, Logger, } from '@metamask/snap-networks-utils'; -import { parseCaipAssetType } from '@metamask/utils'; +import { parseTronCaipAssetType } from '../../utils/caip'; import { BigNumber } from 'bignumber.js'; import type { Types as TronwebTypes } from 'tronweb'; @@ -58,13 +58,13 @@ export class StakingService { */ srNodeAddress?: string; }): Promise { - const { chainId } = parseCaipAssetType(assetId); + const { chainId: scope } = parseTronCaipAssetType(assetId); const amountInSun = Number(trxToSun(amount)); const availableVotes = amount.integerValue(BigNumber.ROUND_DOWN).toNumber(); const voteRecipient = srNodeAddress ?? CONSENSYS_SR_NODE_ADDRESS; this.#logger.info( - `Staking ${amount.toString()} ${assetId} for ${purpose} for ${account.address} on ${chainId}...`, + `Staking ${amount.toString()} ${assetId} for ${purpose} for ${account.address} on ${scope}...`, ); await executeOnChainActions({ @@ -72,7 +72,7 @@ export class StakingService { tronWebFactory: this.#tronWebFactory, snapClient: this.#snapClient, account, - scope: chainId as Network, + scope, buildTransactions: async (tronWeb) => [ await tronWeb.transactionBuilder.freezeBalanceV2( amountInSun, @@ -96,7 +96,7 @@ export class StakingService { assetId: StakedCaipAssetType; amount: BigNumber; }): Promise { - const { chainId } = parseCaipAssetType(assetId); + const { chainId: scope } = parseTronCaipAssetType(assetId); /** * Check which resource we are unstaking. @@ -134,7 +134,7 @@ export class StakingService { const amountInSun = Number(trxToSun(amount)); this.#logger.info( - `Unstaking ${amount.toString()} ${assetId} for ${account.address} on ${chainId}...`, + `Unstaking ${amount.toString()} ${assetId} for ${account.address} on ${scope}...`, ); await executeOnChainActions({ @@ -142,7 +142,7 @@ export class StakingService { tronWebFactory: this.#tronWebFactory, snapClient: this.#snapClient, account, - scope: chainId as Network, + scope, buildTransactions: async (tronWeb) => [ await tronWeb.transactionBuilder.unfreezeBalanceV2( amountInSun, diff --git a/packages/tron-wallet-snap/src/services/state/stateTypes.ts b/packages/tron-wallet-snap/src/services/state/stateTypes.ts index f6648e56a..b9e214b2f 100644 --- a/packages/tron-wallet-snap/src/services/state/stateTypes.ts +++ b/packages/tron-wallet-snap/src/services/state/stateTypes.ts @@ -1,10 +1,10 @@ import type { Transaction } from '@metamask/keyring-api'; -import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; import type { AssetEntity } from '../../entities/assets'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; export type UnencryptedStateValue = { - keyringAccounts: Record; + keyringAccounts: Record; assets: Record; transactions: Record; mapInterfaceNameToId: Record; diff --git a/packages/tron-wallet-snap/src/services/wallet/WalletService.test.ts b/packages/tron-wallet-snap/src/services/wallet/WalletService.test.ts index 3f5bafd25..eee32d166 100644 --- a/packages/tron-wallet-snap/src/services/wallet/WalletService.test.ts +++ b/packages/tron-wallet-snap/src/services/wallet/WalletService.test.ts @@ -1,10 +1,10 @@ -import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; import { SnapError } from '@metamask/snaps-sdk'; import { bytesToBase64, bytesToHex, stringToBytes } from '@metamask/utils'; import { TronWeb } from 'tronweb'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; import { TronMultichainErrors, TronMultichainMethod, @@ -79,7 +79,7 @@ describe('WalletService', () => { address: TEST_ADDRESS, }; - const mockAccount: ExtendedKeyringAccount = { + const mockAccount: TronKeyringAccount = { id: '123e4567-e89b-12d3-a456-426614174000', address: TEST_ADDRESS, options: {}, diff --git a/packages/tron-wallet-snap/src/services/wallet/WalletService.ts b/packages/tron-wallet-snap/src/services/wallet/WalletService.ts index 3f7fbab41..b4dc82e58 100644 --- a/packages/tron-wallet-snap/src/services/wallet/WalletService.ts +++ b/packages/tron-wallet-snap/src/services/wallet/WalletService.ts @@ -1,14 +1,12 @@ import type { ResolvedAccountAddress } from '@metamask/keyring-api'; -import type { - ExtendedKeyringAccount, - Logger, -} from '@metamask/snap-networks-utils'; +import type { Logger } from '@metamask/snap-networks-utils'; import { SnapError } from '@metamask/snaps-sdk'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { bytesToHex, hexToBytes, sha256 } from '@metamask/utils'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import type { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/tronKeyringAccount'; import { TronMultichainErrors, TronMultichainMethod, @@ -67,7 +65,7 @@ export class WalletService { method, params, }: { - account: ExtendedKeyringAccount; + account: TronKeyringAccount; scope: Network; method: TronMultichainMethod; params: Json; @@ -135,7 +133,7 @@ export class WalletService { scope, params, }: { - account: ExtendedKeyringAccount; + account: TronKeyringAccount; scope: Network; params: Json; }): Promise<{ signature: string }> { @@ -197,7 +195,7 @@ export class WalletService { scope, params, }: { - account: ExtendedKeyringAccount; + account: TronKeyringAccount; scope: Network; params: Json; }): Promise<{ signature: string }> { @@ -292,7 +290,7 @@ export class WalletService { * @throws If the request is invalid or no matching account is found. */ async resolveAccountAddress( - keyringAccounts: ExtendedKeyringAccount[], + keyringAccounts: TronKeyringAccount[], scope: Network, request: JsonRpcRequest, ): Promise { diff --git a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx index 70e732995..6b65d0c74 100644 --- a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx +++ b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx @@ -1,4 +1,3 @@ -import type { KeyringRequest } from '@metamask/keyring-api'; import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; import { bytesToBase64, stringToBytes } from '@metamask/utils'; @@ -6,6 +5,7 @@ import type { SnapClient } from '../../../../clients/snap/SnapClient'; import { Network } from '../../../../constants'; import { TronMultichainMethod } from '../../../../handlers/keyring/keyring-types'; import type { Preferences } from '../../../../types/snap'; +import type { TronWalletKeyringRequest } from '../../../../validation/structs'; import { render } from './render'; /** @@ -66,7 +66,7 @@ describe('ConfirmSignMessage render', () => { const testOrigin = 'https://example.com'; const testMessage = 'Hello, Tron!'; - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000001', origin: testOrigin, account: mockAccount.id, @@ -97,7 +97,7 @@ describe('ConfirmSignMessage render', () => { it('decodes base64 message correctly', async () => { const testMessage = 'Test message with special chars: 你好 🚀'; - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000002', origin: 'https://test.com', account: mockAccount.id, @@ -122,7 +122,7 @@ describe('ConfirmSignMessage render', () => { new Error('Failed to load'), ); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000003', origin: 'https://test.com', account: mockAccount.id, @@ -144,7 +144,7 @@ describe('ConfirmSignMessage render', () => { }); it('handles missing origin gracefully', async () => { - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000004', origin: undefined as any, account: mockAccount.id, @@ -165,7 +165,7 @@ describe('ConfirmSignMessage render', () => { }); it('uses correct network scope', async () => { - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000005', origin: 'https://test.com', account: mockAccount.id, @@ -189,7 +189,7 @@ describe('ConfirmSignMessage render', () => { const expectedResult = true; mockSnapClient.showDialog.mockResolvedValue(expectedResult); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000006', origin: 'https://test.com', account: mockAccount.id, @@ -209,7 +209,7 @@ describe('ConfirmSignMessage render', () => { }); it('passes TRX_IMAGE_SVG as network image', async () => { - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000007', origin: 'https://test.com', account: mockAccount.id, diff --git a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx index 865d9b89c..4515d0ee8 100644 --- a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx +++ b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx @@ -1,14 +1,13 @@ -import type { KeyringRequest } from '@metamask/keyring-api'; import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; import type { DialogResult } from '@metamask/snaps-sdk'; import { assert } from '@metamask/superstruct'; -import type { Network } from '../../../../constants'; import snapContext from '../../../../context'; import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; import { formatOrigin } from '../../../../utils/formatOrigin'; import { FALLBACK_LANGUAGE } from '../../../../utils/i18n'; import { SignMessageRequestStruct } from '../../../../validation/structs'; +import type { TronWalletKeyringRequest } from '../../../../validation/structs'; import { ConfirmSignMessage } from './ConfirmSignMessage'; /** @@ -19,7 +18,7 @@ import { ConfirmSignMessage } from './ConfirmSignMessage'; * @returns The confirmation dialog result. */ export async function render( - request: KeyringRequest, + request: TronWalletKeyringRequest, account: ExtendedKeyringAccount, ): Promise { assert(request.request.params, SignMessageRequestStruct); @@ -45,7 +44,7 @@ export async function render( { const testOrigin = 'https://example.com'; const testTransaction = toHex('mock-transaction-data'); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000001', origin: testOrigin, account: mockAccount.id, @@ -313,7 +314,7 @@ describe('ConfirmSignTransaction render', () => { async ({ snapContext, mockSnapClient, mockFeeCalculatorService }) => { mockFeeCalculatorService.computeFee.mockResolvedValue([]); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000002', origin: 'https://test.com', account: mockAccount.id, @@ -358,7 +359,7 @@ describe('ConfirmSignTransaction render', () => { new Error('Scan failed'), ); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000003', origin: 'https://test.com', account: mockAccount.id, @@ -397,7 +398,7 @@ describe('ConfirmSignTransaction render', () => { simulateOnChainActions: false, }); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000004', origin: 'https://test.com', account: mockAccount.id, @@ -434,7 +435,7 @@ describe('ConfirmSignTransaction render', () => { new Error('Failed to load'), ); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000005', origin: 'https://test.com', account: mockAccount.id, @@ -462,7 +463,7 @@ describe('ConfirmSignTransaction render', () => { it('handles missing origin gracefully', async () => { await withSnapContext(async ({ mockSnapClient }) => { - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000006', origin: undefined as any, account: mockAccount.id, @@ -492,7 +493,7 @@ describe('ConfirmSignTransaction render', () => { it('returns the dialog promise result', async () => { await withSnapContext(async () => { - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000007', origin: 'https://test.com', account: mockAccount.id, @@ -526,7 +527,7 @@ describe('ConfirmSignTransaction render', () => { true, ); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000008', origin: 'https://test.com', account: mockAccount.id, @@ -570,7 +571,7 @@ describe('ConfirmSignTransaction render', () => { new Error('schedule failed'), ); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000009', origin: 'https://test.com', account: mockAccount.id, @@ -616,7 +617,7 @@ describe('ConfirmSignTransaction render', () => { throw new Error('update failed'); }); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000010', origin: 'https://test.com', account: mockAccount.id, @@ -658,7 +659,7 @@ describe('ConfirmSignTransaction render', () => { new Error('tapos check failed'), ); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000011', origin: 'https://test.com', account: mockAccount.id, @@ -711,7 +712,7 @@ describe('ConfirmSignTransaction render', () => { ...mockAssets.slice(1), ]); - const request: KeyringRequest = { + const request: TronWalletKeyringRequest = { id: '00000000-0000-4000-8000-000000000012', origin: 'https://example.com', account: mockAccount.id, diff --git a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx index 59ea9fe40..dce56dbcb 100644 --- a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx +++ b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx @@ -1,4 +1,3 @@ -import type { KeyringRequest } from '@metamask/keyring-api'; import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; import type { DialogResult } from '@metamask/snaps-sdk'; import { assert } from '@metamask/superstruct'; @@ -16,6 +15,7 @@ import { FetchStatus } from '../../../../types/snap'; import { sunToTrx } from '../../../../utils/conversion'; import logger from '../../../../utils/logger'; import { SignTransactionRequestStruct } from '../../../../validation/structs'; +import type { TronWalletKeyringRequest } from '../../../../validation/structs'; import { getTransactionTrxValue } from '../../../../validation/transaction'; import { getIconUrlForKnownAsset } from '../../utils/getIconUrlForKnownAsset'; import { ConfirmSignTransaction } from './ConfirmSignTransaction'; @@ -61,7 +61,7 @@ export const DEFAULT_CONTEXT: ConfirmSignTransactionContext = { * @returns The confirmation dialog result. */ export async function render( - request: KeyringRequest, + request: TronWalletKeyringRequest, account: ExtendedKeyringAccount, rawData: TronwebTypes.Transaction['raw_data'], ): Promise { @@ -79,7 +79,7 @@ export async function render( // Build initial context const context: ConfirmSignTransactionContext = { ...DEFAULT_CONTEXT, - scope: scope as Network, + scope, account, transaction, origin: origin ?? 'Unknown', @@ -94,9 +94,9 @@ export async function render( const [preferences, accountAssets] = await Promise.all([ snapClient.getPreferences().catch(() => DEFAULT_CONTEXT.preferences), assetsService.getAccountAssetsByIDs(account.id, [ - Networks[scope as Network].nativeToken.id, - Networks[scope as Network].bandwidth.id, - Networks[scope as Network].energy.id, + Networks[scope].nativeToken.id, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, ]), ]); @@ -131,7 +131,7 @@ export async function render( }; const fees = await feeCalculatorService.computeFee({ - scope: scope as Network, + scope, transaction: transactionObj, availableEnergy, availableBandwidth, @@ -157,9 +157,8 @@ export async function render( context.fees = fees; context.feesFetchStatus = FetchStatus.Fetched; const trxFee = new BigNumber( - fees.find( - (fee) => fee.asset.type === Networks[scope as Network].nativeToken.id, - )?.asset.amount ?? '0', + fees.find((fee) => fee.asset.type === Networks[scope].nativeToken.id) + ?.asset.amount ?? '0', ); const trxBalance = nativeTokenAsset ? sunToTrx(nativeTokenAsset.rawAmount) @@ -214,7 +213,7 @@ export async function render( accountAddress: account.address, transactionRawData: rawData, origin, - scope: scope as Network, + scope, options, }); } catch { @@ -231,7 +230,7 @@ export async function render( const expired = await snapContext.transactionExpirationRefresherService.isTransactionExpired( { - scope: scope as Network, + scope, rawData, }, ); diff --git a/packages/tron-wallet-snap/src/utils/caip.ts b/packages/tron-wallet-snap/src/utils/caip.ts new file mode 100644 index 000000000..34c6fd0d9 --- /dev/null +++ b/packages/tron-wallet-snap/src/utils/caip.ts @@ -0,0 +1,40 @@ +import { assert } from '@metamask/superstruct'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; + +import { Network } from '../constants'; +import { TronCaipAssetTypeStruct } from '../validation/structs'; +import type { + NativeCaipAssetType, + NftCaipAssetType, + ReadyForWithdrawalCaipAssetType, + ResourceCaipAssetType, + StakedCaipAssetType, + StakingRewardsCaipAssetType, + TokenCaipAssetType, +} from '../services/assets/types'; + +export type TronCaipAssetType = + | NativeCaipAssetType + | StakedCaipAssetType + | ReadyForWithdrawalCaipAssetType + | StakingRewardsCaipAssetType + | ResourceCaipAssetType + | TokenCaipAssetType + | NftCaipAssetType; + +export type ParsedTronCaipAssetType = ReturnType & { + chainId: Network; +}; + +export function parseTronCaipAssetType( + assetId: string, +): ParsedTronCaipAssetType { + assert(assetId, TronCaipAssetTypeStruct); + const parsed = parseCaipAssetType(assetId as CaipAssetType); + + return { + ...parsed, + chainId: parsed.chainId as Network, + }; +} diff --git a/packages/tron-wallet-snap/src/utils/getAssetNetwork.test.ts b/packages/tron-wallet-snap/src/utils/getAssetNetwork.test.ts new file mode 100644 index 000000000..2b16c9e99 --- /dev/null +++ b/packages/tron-wallet-snap/src/utils/getAssetNetwork.test.ts @@ -0,0 +1,20 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; + +import { KnownCaip19Id, Network } from '../constants'; +import { getAssetNetwork } from './getAssetNetwork'; + +describe('getAssetNetwork', () => { + it.each([ + [KnownCaip19Id.TrxMainnet, Network.Mainnet], + [KnownCaip19Id.TrxStakedForEnergyNile, Network.Nile], + [KnownCaip19Id.BandwidthShasta, Network.Shasta], + ])('returns the network of %s', (assetType, expected) => { + expect(getAssetNetwork(assetType)).toBe(expected); + }); + + it('throws for an asset on an unsupported chain', () => { + expect(() => getAssetNetwork('eip155:1/slip44:60')).toThrow( + InvalidParamsError, + ); + }); +}); diff --git a/packages/tron-wallet-snap/src/utils/getAssetNetwork.ts b/packages/tron-wallet-snap/src/utils/getAssetNetwork.ts new file mode 100644 index 000000000..e6a61379b --- /dev/null +++ b/packages/tron-wallet-snap/src/utils/getAssetNetwork.ts @@ -0,0 +1,17 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import type { CaipAssetType } from '@metamask/utils'; + +import { assertSupportedNetwork } from '../constants'; +import type { Network } from '../constants'; + +/** + * Returns the supported network a CAIP-19 asset type belongs to. + * + * @param assetType - The CAIP-19 asset type. + * @returns The network of the asset. + * @throws If the asset type's chain is not a supported network. + */ +export function getAssetNetwork(assetType: CaipAssetType): Network { + const { chainId } = parseCaipAssetType(assetType); + return assertSupportedNetwork(chainId); +} diff --git a/packages/tron-wallet-snap/src/validation/structs.test.ts b/packages/tron-wallet-snap/src/validation/structs.test.ts index ce5aa02a6..614c33962 100644 --- a/packages/tron-wallet-snap/src/validation/structs.test.ts +++ b/packages/tron-wallet-snap/src/validation/structs.test.ts @@ -1,9 +1,22 @@ /* eslint-disable jest/require-to-throw-message */ import { assert, is } from '@metamask/superstruct'; +import { Network, Networks } from '../constants'; +import { parseTronCaipAssetType } from '../utils/caip'; import { Base58Struct, Base64Struct } from './structs'; describe('structs', () => { + describe('parseTronCaipAssetType', () => { + it('accepts Tron asset IDs and returns a local network scope', () => { + const parsed = parseTronCaipAssetType(Networks[Network.Mainnet].nativeToken.id); + + expect(parsed.chainId).toBe(Network.Mainnet); + }); + + it('rejects foreign CAIP asset IDs', () => { + expect(() => parseTronCaipAssetType('eip155:1/slip44:60')).toThrow(); + }); + }); describe('Base58Struct', () => { it('validates valid Base58 strings', () => { const validBase58Strings = [ diff --git a/packages/tron-wallet-snap/src/validation/structs.ts b/packages/tron-wallet-snap/src/validation/structs.ts index adb05c4ec..41456726b 100644 --- a/packages/tron-wallet-snap/src/validation/structs.ts +++ b/packages/tron-wallet-snap/src/validation/structs.ts @@ -2,6 +2,7 @@ import { CaipAssetTypeStruct, KeyringRequestStruct, SolMethod, + TransactionType, TrxAccountType, } from '@metamask/keyring-api'; import { ExportAccountOptionsStruct } from '@metamask/keyring-api/v2'; @@ -72,6 +73,18 @@ export const ExportAccountRequestStruct = object({ export const NetworkStruct = enums(Object.values(Network)); +export const ResolveAccountAddressStruct = object({ + scope: NetworkStruct, +}); + +export const TrackTransactionParamsStruct = object({ + txId: string(), + scope: NetworkStruct, + accountIds: array(string()), + attempt: integer(), + transactionType: optional(enums(Object.values(TransactionType))), +}); + /** * Validates createAccount options. * - entropySource: Optional string for the entropy source (UUID or ULID format)