diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index bf3fa557a..f03e8fe28 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -155,6 +155,15 @@ jobs: echo "Working tree dirty at end of job" exit 1 fi + - name: Upload build artifacts + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: package-dist-${{ github.sha }} + # Glob discards the path before the first wildcard (`packages/`), so + # the artifact root is `/dist/...`. Download into `packages/` + # to restore `packages//dist/...`. + path: packages/*/dist + retention-days: 1 test-scripts: name: Test Scripts @@ -181,7 +190,7 @@ jobs: test: name: Test runs-on: ubuntu-latest - needs: prepare + needs: [prepare, build] strategy: matrix: node-version: [20.x, 22.x] @@ -192,8 +201,11 @@ jobs: with: is-high-risk-environment: false node-version: ${{ matrix.node-version }} - - name: Build for tests - run: yarn workspace ${{ matrix.package-name }} build + - name: Restore build artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: package-dist-${{ github.sha }} + path: packages - run: yarn workspace ${{ matrix.package-name }} run test - name: Require clean working directory shell: bash diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 311a6cb59..fa29d1a34 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1510,15 +1510,7 @@ "count": 20 } }, - "packages/tron-wallet-snap/src/caching/InMemoryCache.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/caching/StateCache.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - }, "no-restricted-syntax": { "count": 1 } @@ -1533,20 +1525,7 @@ "count": 2 } }, - "packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/clients/price-api/types.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - }, "no-restricted-syntax": { "count": 5 } @@ -1559,11 +1538,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.test.ts": { "require-atomic-updates": { "count": 1 @@ -1582,16 +1556,6 @@ "count": 42 } }, - "packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/handlers/cronjob/cronjob.test.tsx": { "@typescript-eslint/explicit-function-return-type": { "count": 2 @@ -1610,11 +1574,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/accounts/AccountsService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/assets/AssetsRepository.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2 @@ -1628,16 +1587,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/services/config/ConfigProvider.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts": { "no-restricted-syntax": { "count": 1 @@ -1651,11 +1600,6 @@ "count": 15 } }, - "packages/tron-wallet-snap/src/services/send/SendService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/send/TransactionExpirationRefresherService.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 5 @@ -1666,11 +1610,6 @@ "count": 7 } }, - "packages/tron-wallet-snap/src/services/staking/StakingService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/state/State.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -1679,11 +1618,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/transaction-expiration-refresher/TransactionExpirationRefresherService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts": { "no-restricted-syntax": { "count": 5 @@ -1705,14 +1639,6 @@ }, "@typescript-eslint/no-explicit-any": { "count": 7 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/services/wallet/WalletService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.test.tsx": { @@ -1731,9 +1657,6 @@ "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx": { "@typescript-eslint/no-explicit-any": { "count": 3 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.test.tsx": { @@ -1744,14 +1667,6 @@ "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx": { "@typescript-eslint/no-explicit-any": { "count": 14 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx": { - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx": { @@ -1767,26 +1682,11 @@ "count": 2 } }, - "packages/tron-wallet-snap/src/utils/deriveTronFromCoinTypeNode.test.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/utils/deriveTronFromCoinTypeNode.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts": { "n/no-process-env": { "count": 3 } }, - "packages/tron-wallet-snap/src/validation/keyring-structs.test.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/validation/transaction.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/package.json b/package.json index 30b7a30e1..170e1a371 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ ], "files": [], "scripts": { - "build": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run build", + "build": "yarn workspaces foreach --all --no-private --topological-dev --parallel --interlaced --verbose run build", "build:clean": "yarn build:only-clean && yarn build", "build:docs": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run build:docs", "build:only-clean": "rimraf -g 'packages/*/dist'", diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index bcc74af70..d54ba186d 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Core messenger plumbing (`getMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`) for upcoming AssetsController migration ([#95](https://github.com/MetaMask/internal-snaps/pull/95)) +- Route fungible asset reads through Core AssetsController based on migration stage ([#127](https://github.com/MetaMask/internal-snaps/pull/127)) + +### Removed + +- Assets migration feature-flag routing. Fungible reads (`getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`) now always use Core `AssetsController` via `AssetsProvider`; snap-owned protocol assets remain on the Snap adapter for sync, reads, and keyring events. Removed `RemoteFeatureFlagController:getState` messenger endowment ([#97](https://github.com/MetaMask/internal-snaps/pull/97)) + ## [3.0.0] ### Added diff --git a/packages/tron-wallet-snap/package.json b/packages/tron-wallet-snap/package.json index a5b4812d4..419fd07df 100644 --- a/packages/tron-wallet-snap/package.json +++ b/packages/tron-wallet-snap/package.json @@ -47,11 +47,17 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/utils": "^11.9.0" + }, "devDependencies": { + "@metamask/assets-controller": "^13.0.0", "@metamask/auto-changelog": "^6.1.1", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^23.7.0", "@metamask/keyring-snap-sdk": "^9.2.1", + "@metamask/messenger": "^2.0.0", + "@metamask/snap-networks-utils": "^1.0.0", "@metamask/snaps-cli": "^8.4.1", "@metamask/snaps-jest": "^10.2.0", "@metamask/snaps-sdk": "^11.2.0", diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index cd9547607..1580ffb35 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": "s5YqLxf2VlRkjAfbTnHJCZXjWGdcaOi3h0JCe+wHeXo=", + "shasum": "6dgldMOKKiG/d+FsXzdI0Smd3MfJn7MCqhykDVXeRf8=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -63,6 +63,13 @@ }, "endowment:assets": { "scopes": ["tron:728126428"] + }, + "endowment:messenger": { + "actions": [ + "AssetsController:getAccountAssetByID", + "AssetsController:getAccountAssetsByIDs", + "AssetsController:getAccountAssetsByScope" + ] } }, "platformVersion": "11.2.0", diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index db5c96417..b6d738a17 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -1,3 +1,7 @@ +import { AssetsProvider } from '@metamask/snap-networks-utils'; +import type { AssetsProviderMessenger } from '@metamask/snap-networks-utils'; +import { getMessenger } from '@metamask/snaps-sdk'; + import { InMemoryCache } from './caching/InMemoryCache'; import { StateCache } from './caching/StateCache'; import { PriceApiClient } from './clients/price-api/PriceApiClient'; @@ -29,6 +33,10 @@ import { TransactionScanService } from './services/transaction-scan/TransactionS import { TransactionsRepository } from './services/transactions/TransactionsRepository'; import { TransactionsService } from './services/transactions/TransactionsService'; import { WalletService } from './services/wallet/WalletService'; +import type { + CoreMessenger, + CoreMessengerMessenger, +} from './types/core-messenger'; import logger, { noOpLogger } from './utils/logger'; /** @@ -82,13 +90,21 @@ const priceApiClient = new PriceApiClient(configProvider, priceCache); // Token API client const tokenApiClient = new TokenApiClient(configProvider); +/** + * Core controllers plumbing + */ +const coreMessenger = getMessenger(); +const assetsProvider = new AssetsProvider({ + messenger: coreMessenger as AssetsProviderMessenger, +}); + // Security Alerts API client const securityAlertsApiClient = new SecurityAlertsApiClient( configProvider, logger, ); -// Business Services - depend on Repositories, State and other Services +// Business Services const assetsService = new AssetsService({ logger, state, @@ -98,6 +114,7 @@ const assetsService = new AssetsService({ priceApiClient, tokenApiClient, snapClient, + assetsProvider, }); const transactionsService = new TransactionsService({ @@ -235,6 +252,11 @@ export type SnapExecutionContext = { confirmationHandler: ConfirmationHandler; transactionScanService: TransactionScanService; transactionExpirationRefresherService: TransactionExpirationRefresherService; + /** + * Core messenger plumbing for AssetsController reads. + */ + coreMessenger: CoreMessenger; + assetsProvider: AssetsProvider; /** * Handlers */ @@ -267,6 +289,8 @@ const snapContext: SnapExecutionContext = { confirmationHandler, transactionScanService, transactionExpirationRefresherService, + coreMessenger, + assetsProvider, /** * Handlers */ diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts index 549b55ec8..91cc26668 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -1,7 +1,10 @@ +import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { AssetsProvider } from '@metamask/snap-networks-utils'; +import { MOCK_EXCHANGE_RATES } from '../../clients/price-api/mocks/exchange-rates'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { SpotPrices } from '../../clients/price-api/types'; import type { SnapClient } from '../../clients/snap/SnapClient'; @@ -9,9 +12,10 @@ import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { AccountResources, TronHttpClient } from '../../clients/tron-http'; import { TrongridAccountNotFoundError } from '../../clients/trongrid/errors'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { Trc20Balance, TronAccount } from '../../clients/trongrid/types'; -import { KnownCaip19Id, Network } from '../../constants'; +import type { TronAccount } from '../../clients/trongrid/types'; +import { KnownCaip19Id, Network, SNAP_OWNED_ASSETS } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; +import type { CoreMessengerCaller } from '../../types/core-messenger'; import { mockLogger } from '../../utils/mockLogger'; import type { AssetsRepository } from './AssetsRepository'; import type { NativeCaipAssetType, TokenCaipAssetType } from './types'; @@ -51,6 +55,67 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports const { AssetsService } = require('./AssetsService'); +function createMessengerCallMock( + getAccountAssetByID: jest.Mock, + getAccountAssetsByIDs: jest.Mock = jest.fn().mockResolvedValue({}), + getAccountAssetsByScope: jest.Mock = jest.fn().mockResolvedValue({}), +): CoreMessengerCaller['call'] { + return async (actionType, ...args) => { + switch (actionType) { + case 'AssetsController:getAccountAssetByID': + return getAccountAssetByID(...args); + case 'AssetsController:getAccountAssetsByIDs': + return getAccountAssetsByIDs(...args); + case 'AssetsController:getAccountAssetsByScope': + return getAccountAssetsByScope(...args); + default: + return undefined; + } + }; +} + +function buildControllerAsset( + assetId: string, + amount: string, + metadata: { + symbol: string; + name: string; + decimals: number; + image?: string; + }, +): Asset { + return { + id: assetId as Asset['id'], + chainId: Network.Mainnet as Asset['chainId'], + balance: { amount }, + metadata: { + type: 'fungible', + symbol: metadata.symbol, + name: metadata.name, + decimals: metadata.decimals, + image: metadata.image, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as Asset; +} + +/** + * Builds a SpotPrices map for test mocks. + * + * @param entries - Map of asset ID to price info. + * @returns SpotPrices object. + */ +const createSpotPrices = ( + entries: Record, +): SpotPrices => + Object.fromEntries( + Object.entries(entries).map(([key, value]) => [ + key, + { id: value.id, price: value.price }, + ]), + ); + const mockAccount: KeyringAccount = { id: 'test-account-id', address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', @@ -72,22 +137,6 @@ const emptyAccountResources: AccountResources = { TotalEnergyWeight: 0, }; -/** - * Creates properly typed SpotPrices for tests. - * - * @param entries - Map of asset ID to price info. - * @returns SpotPrices object. - */ -const createSpotPrices = ( - entries: Record, -): SpotPrices => - Object.fromEntries( - Object.entries(entries).map(([key, value]) => [ - key, - { id: value.id, price: value.price }, - ]), - ); - /** * Creates a properly typed TronAccount for tests. * Uses snake_case property names to match Tron API response format. @@ -187,6 +236,7 @@ type WithAssetsServiceCallback = (payload: { >; mockTokenApiClient: jest.Mocked>; mockSnapClient: jest.Mocked>; + mockCoreMessenger: jest.Mocked; }) => Promise | ReturnValue; /** @@ -259,6 +309,25 @@ async function withAssetsService( trackError: jest.fn().mockResolvedValue(undefined), }; + const mockGetAccountAssetByID = jest.fn(); + const mockGetAccountAssetsByIDs = jest.fn().mockResolvedValue({}); + const mockGetAccountAssetsByScope = jest.fn().mockResolvedValue({}); + const mockCoreMessenger: jest.Mocked = { + call: jest + .fn() + .mockImplementation( + createMessengerCallMock( + mockGetAccountAssetByID, + mockGetAccountAssetsByIDs, + mockGetAccountAssetsByScope, + ), + ), + }; + + const assetsProvider = new AssetsProvider({ + messenger: mockCoreMessenger as never, + }); + const assetsService = new AssetsService({ logger: mockLogger, assetsRepository: mockAssetsRepository, @@ -268,6 +337,7 @@ async function withAssetsService( priceApiClient: mockPriceApiClient, tokenApiClient: mockTokenApiClient, snapClient: mockSnapClient, + assetsProvider, }); return await testFunction({ @@ -279,19 +349,19 @@ async function withAssetsService( mockPriceApiClient, mockTokenApiClient, mockSnapClient, + mockCoreMessenger, }); } describe('AssetsService', () => { describe('fetchAssetsAndBalancesForAccount', () => { describe('inactive account fallback', () => { - it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { + it('skips TRC20 fallback and returns protocol assets only for inactive accounts', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), @@ -300,20 +370,6 @@ describe('AssetsService', () => { emptyAccountResources, ); - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, mockAccount, @@ -321,27 +377,23 @@ describe('AssetsService', () => { expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const expectedTrc20AssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const trc20Asset = assets.find( - (asset: AssetEntity) => - asset.assetType === expectedTrc20AssetType, - ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('24249143'); + ).not.toHaveBeenCalled(); + expect( + assets.every((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(false); }, ); }); - it('returns zero TRX and resources when fallback also returns empty', async () => { + it('returns protocol resources when inactive account has empty resources', async () => { await withAssetsService( async ({ assetsService, @@ -354,9 +406,6 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue( emptyAccountResources, ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, @@ -365,14 +414,7 @@ describe('AssetsService', () => { expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); + ).not.toHaveBeenCalled(); const bandwidthAsset = assets.find( (asset: AssetEntity) => @@ -388,75 +430,12 @@ describe('AssetsService', () => { ); }); - it('gracefully handles fallback endpoint failure', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - new Error('Network error'), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('tracks fallback endpoint errors', async () => { - await withAssetsService( - async ({ - assetsService, - mockSnapClient, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const error = new Error('Network error'); - - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - error, - ); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); - - it('filters out TRC20 tokens without price data from inactive account', async () => { + it('returns protocol assets when inactive account info fails', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), @@ -465,50 +444,29 @@ describe('AssetsService', () => { emptyAccountResources, ); - const trc20BalancesWithSpam: Trc20Balance[] = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price - { TSpamToken123456789: '1000000000' }, // Spam token - no price - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20BalancesWithSpam, - ); - - const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [usdtAssetId]: { id: usdtAssetId, price: 1.0 }, - }), - ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, mockAccount, ); - const usdtAssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const usdtAsset = assets.find( - (asset: AssetEntity) => asset.assetType === usdtAssetType, - ); - expect(usdtAsset).toBeDefined(); - - const spamAssetType = `${String(Network.Mainnet)}/trc20:TSpamToken123456789`; - const spamAsset = assets.find( - (asset: AssetEntity) => asset.assetType === spamAssetType, - ); - expect(spamAsset).toBeUndefined(); + expect(assets.length).toBeGreaterThan(0); + expect( + assets.every((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); }, ); }); }); describe('partial failure handling', () => { - it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { + it('returns protocol assets when account info fails even if resources succeed (inactive account)', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), @@ -520,20 +478,6 @@ describe('AssetsService', () => { EnergyLimit: 0, }); - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, mockAccount, @@ -541,20 +485,19 @@ describe('AssetsService', () => { expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + ).not.toHaveBeenCalled(); + expect( + assets.every((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); - const trxAsset = assets.find( + const bandwidthAsset = assets.find( (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const trc20Asset = assets.find( - (asset: AssetEntity) => asset.assetType === trc20AssetId, + asset.assetType === KnownCaip19Id.BandwidthMainnet, ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('100000'); + expect(bandwidthAsset).toBeDefined(); + expect(bandwidthAsset?.rawAmount).toBe('600'); }, ); }); @@ -582,12 +525,12 @@ describe('AssetsService', () => { mockAccount, ); - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('1000000'); + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(false); const bandwidthAsset = assets.find( (asset: AssetEntity) => @@ -598,38 +541,6 @@ describe('AssetsService', () => { }, ); }); - - it('tracks spot price errors', async () => { - await withAssetsService( - async ({ - assetsService, - mockSnapClient, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - const error = new Error('Spot price endpoint unavailable'); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - balance: 1000000, - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockPriceApiClient.getMultipleSpotPrices.mockRejectedValue(error); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); }); describe('bandwidth', () => { @@ -1510,7 +1421,12 @@ describe('AssetsService', () => { await assetsService.saveMany(assets); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + const snapOwnedAssets = assets.filter((asset) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ); + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + snapOwnedAssets, + ); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1518,7 +1434,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.BandwidthMainnet, ]), @@ -1531,83 +1446,13 @@ describe('AssetsService', () => { ); }); - it('correctly updates non-essential assets with zero amounts', async () => { - await withAssetsService(async ({ assetsService, mockState }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: assets, - }); - - await assetsService.saveMany(assets); - - expect(await assetsService.getAll()).toStrictEqual(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], - removed: [trc20AssetId], - }, - }, - }, - ); - }); - }); - - it('updates stale non-essential assets balance to 0 if missed from the latest snapshot', async () => { + it('does not persist or emit fungible assets', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId as TokenCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '1658250000', - uiAmount: '1658.25', - iconUrl: '', - }, - ]; - const finalSavedAssets: AssetEntity[] = [ + const assets: AssetEntity[] = [ { - assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, + assetType: KnownCaip19Id.TrxMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'TRX', @@ -1617,7 +1462,7 @@ describe('AssetsService', () => { iconUrl: '', }, { - assetType: trc20AssetId as TokenCaipAssetType, + assetType: trc20AssetId, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'USDT', @@ -1628,61 +1473,90 @@ describe('AssetsService', () => { }, ]; - const updatedAssets: AssetEntity[] = [savedAssets[0] as AssetEntity]; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, + [mockAccount.id]: assets, }); - // If an asset is missing from the received list - // - emits the event 'notify:accountAssetListUpdated' with the asset in the 'removed' property - // - emits the event 'notify:accountBalancesUpdated' with the balance for the removed asset sets to 0 - await assetsService.saveMany(updatedAssets); + await assetsService.saveMany(assets); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - finalSavedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith([]); + expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], - removed: [trc20AssetId], - }, - }, - }, - ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [trc20AssetId]: { - unit: 'USDT', - amount: '0', - }, - }, - }, - }, ); }, ); }); - it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { + it('does not zero or remove TRC20 when missing from snap-owned sync snapshot', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const savedAssets: AssetEntity[] = [ { - assetType: KnownCaip19Id.TrxMainnet, + assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: trc20AssetId as TokenCaipAssetType, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '1658250000', + uiAmount: '1658.25', + iconUrl: '', + }, + ]; + const incomingSnapOwned: AssetEntity[] = [ + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(incomingSnapOwned); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + incomingSnapOwned, + ); + const persistedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + persistedAssets.find((asset) => asset.assetType === trc20AssetId), + ).toBeUndefined(); + expect( + persistedAssets.find( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBeUndefined(); + }, + ); + }); + + it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const assets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'TRX', @@ -1695,7 +1569,7 @@ describe('AssetsService', () => { assetType: KnownCaip19Id.MaximumEnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'MAX-ENERGY', + symbol: 'MAXIMUM-ENERGY', decimals: 0, rawAmount: '0', uiAmount: '0', @@ -1705,7 +1579,7 @@ describe('AssetsService', () => { assetType: KnownCaip19Id.MaximumBandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'MAX-BANDWIDTH', + symbol: 'MAXIMUM-BANDWIDTH', decimals: 0, rawAmount: '0', uiAmount: '0', @@ -1717,7 +1591,11 @@ describe('AssetsService', () => { await assetsService.saveMany(assets); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + assets.filter((asset) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1725,7 +1603,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.MaximumEnergyMainnet, KnownCaip19Id.MaximumBandwidthMainnet, ]), @@ -1756,7 +1633,7 @@ describe('AssetsService', () => { assetType: KnownCaip19Id.TrxStakedForBandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'sTRX-BANDWIDTH', + symbol: 'STAKED-BANDWIDTH', decimals: 6, rawAmount: '0', uiAmount: '0', @@ -1766,7 +1643,7 @@ describe('AssetsService', () => { assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'sTRX-ENERGY', + symbol: 'STAKED-ENERGY', decimals: 6, rawAmount: '0', uiAmount: '0', @@ -1778,7 +1655,11 @@ describe('AssetsService', () => { await assetsService.saveMany(assets); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + assets.filter((asset) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1786,7 +1667,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxStakedForBandwidthMainnet, KnownCaip19Id.TrxStakedForEnergyMainnet, ]), @@ -1803,21 +1683,11 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, { assetType: KnownCaip19Id.TrxReadyForWithdrawalMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'trx-ready-for-withdrawal', + symbol: 'READY-FOR-WITHDRAWAL', decimals: 6, rawAmount: '0', uiAmount: '0', @@ -1836,10 +1706,7 @@ describe('AssetsService', () => { { assets: { [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ]), + added: [KnownCaip19Id.TrxReadyForWithdrawalMainnet], removed: [], }, }, @@ -1849,21 +1716,11 @@ describe('AssetsService', () => { ); }); - describe('updating assets from 0 to >0', () => { + describe('updating snap-owned assets', () => { it('adds energy to the asset list when it updates from 0 to >0', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, + const previousAssets: AssetEntity[] = [ { assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, @@ -1875,32 +1732,21 @@ describe('AssetsService', () => { iconUrl: '', }, ]; - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, { assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'ENERGY', decimals: 0, - rawAmount: '50000', - uiAmount: '50000', + rawAmount: '100', + uiAmount: '100', iconUrl: '', }, ]; mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, + [mockAccount.id]: previousAssets, }); await assetsService.saveMany(updatedAssets); @@ -1914,15 +1760,26 @@ describe('AssetsService', () => { { assets: { [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), + added: [KnownCaip19Id.EnergyMainnet], removed: [], }, }, }, ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '100', + }, + }, + }, + }, + ); }, ); }); @@ -1930,17 +1787,7 @@ describe('AssetsService', () => { it('adds bandwidth to the asset list when it updates from 0 to >0', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, + const previousAssets: AssetEntity[] = [ { assetType: KnownCaip19Id.BandwidthMainnet, keyringAccountId: mockAccount.id, @@ -1952,32 +1799,21 @@ describe('AssetsService', () => { iconUrl: '', }, ]; - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, { assetType: KnownCaip19Id.BandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'BANDWIDTH', decimals: 0, - rawAmount: '1500', - uiAmount: '1500', + rawAmount: '600', + uiAmount: '600', iconUrl: '', }, ]; mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, + [mockAccount.id]: previousAssets, }); await assetsService.saveMany(updatedAssets); @@ -1991,10 +1827,7 @@ describe('AssetsService', () => { { assets: { [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), + added: [KnownCaip19Id.BandwidthMainnet], removed: [], }, }, @@ -2004,59 +1837,36 @@ describe('AssetsService', () => { ); }); - it('adds TRC20 token to the asset list when it updates from 0 to >0', async () => { + it('updates energy balance when it decreases but remains >0', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, + const previousAssets: AssetEntity[] = [ { - assetType: trc20AssetId, + assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', + symbol: 'ENERGY', + decimals: 0, + rawAmount: '1000', + uiAmount: '1000', iconUrl: '', }, ]; - const updatedAssets: AssetEntity[] = [ { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, + assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '100000000', + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', uiAmount: '100', iconUrl: '', }, ]; mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, + [mockAccount.id]: previousAssets, }); await assetsService.saveMany(updatedAssets); @@ -2066,15 +1876,14 @@ describe('AssetsService', () => { ); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), - KeyringEvent.AccountAssetListUpdated, + KeyringEvent.AccountBalancesUpdated, { - assets: { + balances: { [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - trc20AssetId, - ]), - removed: [], + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '100', + }, }, }, }, @@ -2083,99 +1892,36 @@ describe('AssetsService', () => { ); }); - it('handles multiple assets updating from 0 to >0 simultaneously', async () => { + it('keeps energy in the list when it drops to 0', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, + const previousAssets: AssetEntity[] = [ { assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'ENERGY', decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', + rawAmount: '100', + uiAmount: '100', iconUrl: '', }, ]; - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, { assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'ENERGY', decimals: 0, - rawAmount: '50000', - uiAmount: '50000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1500', - uiAmount: '1500', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '100000000', - uiAmount: '100', + rawAmount: '0', + uiAmount: '0', iconUrl: '', }, ]; mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, + [mockAccount.id]: previousAssets, }); await assetsService.saveMany(updatedAssets); @@ -2189,12 +1935,7 @@ describe('AssetsService', () => { { assets: { [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - trc20AssetId, - ]), + added: [KnownCaip19Id.EnergyMainnet], removed: [], }, }, @@ -2204,57 +1945,56 @@ describe('AssetsService', () => { ); }); - it('handles staked assets updating from 0 to >0', async () => { + it('handles both energy and bandwidth fluctuating in a transaction', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ + const previousAssets: AssetEntity[] = [ { - assetType: KnownCaip19Id.TrxMainnet, + assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '5000000', - uiAmount: '5', + symbol: 'ENERGY', + decimals: 0, + rawAmount: '1000', + uiAmount: '1000', iconUrl: '', }, { - assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, + assetType: KnownCaip19Id.BandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: '0', - uiAmount: '0', + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '600', + uiAmount: '600', iconUrl: '', }, ]; - const updatedAssets: AssetEntity[] = [ { - assetType: KnownCaip19Id.TrxMainnet, + assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', + symbol: 'ENERGY', + decimals: 0, + rawAmount: '900', + uiAmount: '900', iconUrl: '', }, { - assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, + assetType: KnownCaip19Id.BandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: '3000000', - uiAmount: '3', + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '500', + uiAmount: '500', iconUrl: '', }, ]; mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, + [mockAccount.id]: previousAssets, }); await assetsService.saveMany(updatedAssets); @@ -2264,15 +2004,18 @@ describe('AssetsService', () => { ); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), - KeyringEvent.AccountAssetListUpdated, + KeyringEvent.AccountBalancesUpdated, { - assets: { + balances: { [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.TrxStakedForEnergyMainnet, - ]), - removed: [], + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '900', + }, + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '500', + }, }, }, }, @@ -2281,550 +2024,358 @@ describe('AssetsService', () => { ); }); }); + }); - describe('updating assets going down', () => { - it('updates energy balance when it decreases but remains >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '100000', - uiAmount: '100000', - iconUrl: '', - }, - ]; + describe('getAssetsMetadata', () => { + it('resolves metadata for native, protocol, and token asset types', async () => { + await withAssetsService(async ({ assetsService, mockTokenApiClient }) => { + const trc20 = + `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType; + const trc10 = `${Network.Mainnet}/trc10:1002000` as TokenCaipAssetType; - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '35000', - uiAmount: '35000', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + mockTokenApiClient.getTokensMetadata.mockResolvedValue({ + [trc20]: { + fungible: { symbol: 'USDT', name: 'Tether', decimals: 6 }, + }, + [trc10]: { + fungible: { symbol: 'T', name: 'Token', decimals: 0 }, + }, + } as never); + + const assetTypes = [ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + KnownCaip19Id.TrxInLockPeriodMainnet, + KnownCaip19Id.TrxStakingRewardsMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.MaximumBandwidthMainnet, + trc10, + trc20, + ]; - await assetsService.saveMany(updatedAssets); + const metadata = await assetsService.getAssetsMetadata(assetTypes); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [KnownCaip19Id.EnergyMainnet]: { - unit: 'ENERGY', - amount: '35000', - }, - }, - }, - }, - ); - }, - ); + expect(metadata[KnownCaip19Id.TrxMainnet]?.symbol).toBe('TRX'); + expect(metadata[KnownCaip19Id.EnergyMainnet]?.symbol).toBe('ENERGY'); + expect(metadata[trc20]?.fungible?.symbol).toBe('USDT'); + expect(mockTokenApiClient.getTokensMetadata).toHaveBeenCalledWith([ + trc10, + trc20, + ]); }); + }); + }); - it('updates bandwidth balance when it decreases but remains >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '5000', - uiAmount: '5000', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '4700', - uiAmount: '4700', - iconUrl: '', - }, - ]; + describe('AssetsController routing', () => { + const accountId = mockAccount.id; + const fungibleAssetId = KnownCaip19Id.TrxMainnet; + const snapAssetId = KnownCaip19Id.EnergyMainnet; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + it('fetchAssetsAndBalancesForAccount returns protocol assets only', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1_000_000, + trc20: [{ TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '1000' }], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); - await assetsService.saveMany(updatedAssets); + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); + expect( + assets.every((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === fungibleAssetId, + ), + ).toBe(false); + }, + ); + }); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '4700', - }, - }, - }, - }, - ); - }, - ); - }); + it('routes snap-owned reads through the repository', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + const snapAsset: AssetEntity = { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); - it('updates TRC20 token balance when it decreases but remains >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const asset = await assetsService.getAccountAssetByID( + accountId, + snapAssetId, + ); - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '100000000', - uiAmount: '100', - iconUrl: '', - }, - ]; + expect(asset).toStrictEqual(snapAsset); + expect(mockCoreMessenger.call).not.toHaveBeenCalledWith( + 'AssetsController:getAccountAssetByID', + expect.anything(), + expect.anything(), + ); + }, + ); + }); - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, + it('routes fungible reads through AssetsController', async () => { + await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + jest.fn().mockResolvedValue( + buildControllerAsset(fungibleAssetId, '2000000', { symbol: 'TRX', + name: 'TRON', decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '50000000', - uiAmount: '50', - iconUrl: '', - }, - ]; + }), + ), + ), + ); - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + const asset = await assetsService.getAccountAssetByID( + accountId, + fungibleAssetId, + ); - await assetsService.saveMany(updatedAssets); + expect(asset).toMatchObject({ + assetType: fungibleAssetId, + rawAmount: '2000000', + uiAmount: '2', + }); + }); + }); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - trc20AssetId, - ]), - removed: [], - }, - }, - }, - ); - }, + it('getAccountAssetsByIDs uses a single AssetsController:getAccountAssetsByIDs call for fungibles', async () => { + await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { + const trx = KnownCaip19Id.TrxMainnet; + const usdt = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + jest.fn(), + jest.fn().mockImplementation(async () => { + return { + [trx as Caip19AssetId]: buildControllerAsset(trx, '1000000', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + [usdt as Caip19AssetId]: buildControllerAsset(usdt, '500000', { + symbol: 'USDT', + name: 'Tether', + decimals: 6, + }), + }; + }), + ), ); - }); - it('keeps energy in the list when it drops to 0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '50000', - uiAmount: '50000', - iconUrl: '', - }, - ]; + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + trx, + usdt, + ]); - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + accountId, + [trx, usdt], + ); + expect(results[0]?.rawAmount).toBe('1000000'); + expect(results[1]?.rawAmount).toBe('500000'); + }); + }); - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + it('getAccountAssetsByIDs batches snap-owned reads without calling AssetsController', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + const snapAsset: AssetEntity = { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); - await assetsService.saveMany(updatedAssets); + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + snapAssetId, + ]); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); + expect(results[0]).toStrictEqual(snapAsset); + expect(mockCoreMessenger.call).not.toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + expect.anything(), + expect.anything(), + ); + }, + ); + }); - it('keeps bandwidth in the list when it drops to 0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '300', - uiAmount: '300', - iconUrl: '', - }, - ]; + it('getAccountAssetsByIDs merges snap-owned and fungible reads in request order', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + const snapAsset: AssetEntity = { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '250', + uiAmount: '250', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + jest.fn(), + jest.fn().mockImplementation(async () => { + return { + [fungibleAssetId as Caip19AssetId]: buildControllerAsset( + fungibleAssetId, + '3000000', + { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }, + ), + }; + }), + ), + ); - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + fungibleAssetId, + snapAssetId, + ]); - await assetsService.saveMany(updatedAssets); + expect(results[0]?.rawAmount).toBe('3000000'); + expect(results[1]).toStrictEqual(snapAsset); + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + accountId, + [fungibleAssetId], + ); + }, + ); + }); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); + it('getByKeyringAccountId returns snap-owned assets only', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + mockAssetsRepository.getByAccountId.mockResolvedValue([ + { + assetType: fungibleAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }, + ]); - it('handles both energy and bandwidth fluctuating in a transaction', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '80000', - uiAmount: '80000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1500', - uiAmount: '1500', - iconUrl: '', - }, - ]; + const assets = await assetsService.getByKeyringAccountId(accountId); - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '45000', - uiAmount: '45000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1235', - uiAmount: '1235', - iconUrl: '', - }, - ]; + expect(mockCoreMessenger.call).not.toHaveBeenCalled(); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === fungibleAssetId, + ), + ).toBe(false); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === snapAssetId, + ), + ).toBe(true); + }, + ); + }); - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + it('saveMany emits only snap-owned assets', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + mockState.getKey.mockResolvedValue({}); - await assetsService.saveMany(updatedAssets); + const assets: AssetEntity[] = [ + { + assetType: fungibleAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }, + ]; - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); + await assetsService.saveMany(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '2', - }, - [KnownCaip19Id.EnergyMainnet]: { - unit: 'ENERGY', - amount: '45000', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '1235', - }, - }, - }, + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [accountId]: { + added: [snapAssetId], + removed: [], }, - ); + }, }, ); }); @@ -2832,7 +2383,7 @@ describe('AssetsService', () => { }); describe('facade delegation', () => { - it('delegates repository reads and market helpers to SnapAssetsAdapter', async () => { + it('delegates static helpers and empty batch reads to SnapAssetsAdapter', async () => { await withAssetsService( async ({ assetsService, mockAssetsRepository, mockPriceApiClient }) => { const asset: AssetEntity = { @@ -2843,18 +2394,15 @@ describe('AssetsService', () => { decimals: 6, rawAmount: '1', uiAmount: '1', + iconUrl: '', }; - mockAssetsRepository.getByAccountId.mockResolvedValue([asset]); mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ asset, ]); - mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( - asset, + mockPriceApiClient.getFiatExchangeRates.mockResolvedValue( + MOCK_EXCHANGE_RATES, ); - mockPriceApiClient.getFiatExchangeRates.mockResolvedValue({ - usd: { value: 1 }, - }); mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( createSpotPrices({ [KnownCaip19Id.TrxMainnet]: { @@ -2868,39 +2416,17 @@ describe('AssetsService', () => { expect(AssetsService.isFiat('swift:0/iso4217:usd')).toBe(true); expect(AssetsService.hasChanged(asset, [])).toBe(true); expect(AssetsService.hasChanged(asset, [asset])).toBe(false); - expect( - await assetsService.getAccountAssets(mockAccount.id), - ).toStrictEqual([asset]); + await assetsService.getAccountAssetsByIDs(mockAccount.id, []), + ).toStrictEqual([]); expect( - await assetsService.getAccountAssetsByIDs(mockAccount.id, [ - KnownCaip19Id.TrxMainnet, + await assetsService.getMultipleTokensMarketData([ + { + asset: KnownCaip19Id.TrxMainnet, + unit: 'swift:0/iso4217:usd', + }, ]), - ).toStrictEqual([asset]); - expect( - await assetsService.getAccountAssetByID( - mockAccount.id, - KnownCaip19Id.TrxMainnet, - ), - ).toStrictEqual(asset); - const byKeyringAccountId = await assetsService.getByKeyringAccountId( - mockAccount.id, - ); - expect( - byKeyringAccountId.some( - (savedAsset) => savedAsset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(true); - const marketData = await assetsService.getMultipleTokensMarketData([ - { - asset: KnownCaip19Id.TrxMainnet, - unit: 'swift:0/iso4217:usd', - }, - ]); - expect(marketData[KnownCaip19Id.TrxMainnet]).toBeDefined(); - expect(assetsService.cacheTtlsMilliseconds.historicalPrices).toBe( - 3600000, - ); + ).toBeDefined(); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 6ba8e4a37..34f9f091b 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,4 +1,6 @@ +import type { Caip19AssetId } from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; import type { AssetConversion, AssetMetadata, @@ -12,21 +14,20 @@ import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { Network } from '../../constants'; +import { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; import type { ILogger } from '../../utils/logger'; import type { State, UnencryptedStateValue } from '../state/State'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; +import { mapControllerAsset } from './mapControllerAsset'; +import { isSnapOwnedAsset } from './snapOwnedAssets'; -/** - * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter - * (legacy snap-owned reads/writes). Core adapter routing can be introduced later - * without changing callers. - */ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; + readonly #assetsProvider: AssetsProvider; + readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; constructor({ @@ -38,6 +39,7 @@ export class AssetsService { priceApiClient, tokenApiClient, snapClient, + assetsProvider, }: { logger: ILogger; assetsRepository: AssetsRepository; @@ -47,7 +49,10 @@ export class AssetsService { priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; + assetsProvider: AssetsProvider; }) { + this.#assetsProvider = assetsProvider; + this.#snapAdapter = new SnapAssetsAdapter({ logger, assetsRepository, @@ -69,22 +74,143 @@ export class AssetsService { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } - async getAccountAssets(accountId: string): Promise { - return this.#snapAdapter.getAccountAssets(accountId); + async #getProviderAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + const asset = await this.#assetsProvider.getAccountAssetByID( + accountId, + assetId as Caip19AssetId, + ); + + if (!asset) { + return null; + } + + return mapControllerAsset(accountId, asset); } - async getAccountAssetsByIDs( + async #getProviderAccountAssetsByIDs( accountId: string, - assetTypes: string[], - ): Promise<(AssetEntity | null)[]> { - return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetTypes); + assetIds: string[], + ): Promise> { + const controllerAssets = await this.#assetsProvider.getAccountAssetsByIDs( + accountId, + assetIds as Caip19AssetId[], + ); + + return Object.fromEntries( + assetIds.map((assetId) => { + const controllerAsset = controllerAssets[assetId as Caip19AssetId]; + return [ + assetId, + controllerAsset + ? mapControllerAsset(accountId, controllerAsset) + : null, + ]; + }), + ); + } + + async #getProviderAccountAssetsByScope( + scope: Network, + accountId: string, + ): Promise { + const controllerAssets = await this.#assetsProvider.getAccountAssetsByScope( + scope, + accountId, + ); + + return Object.values(controllerAssets).map((asset) => + mapControllerAsset(accountId, asset), + ); } async getAccountAssetByID( accountId: string, - assetType: string, + assetId: string, ): Promise { - return this.#snapAdapter.getAccountAssetByID(accountId, assetType); + if (isSnapOwnedAsset(assetId)) { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } + + return this.#getProviderAccountAssetByID(accountId, assetId); + } + + async getAccountAssetsByIDs( + accountId: string, + assetIds: string[], + ): Promise<(AssetEntity | null)[]> { + if (assetIds.length === 0) { + return []; + } + + const result: (AssetEntity | null)[] = new Array(assetIds.length).fill( + null, + ); + const fungibleIds: string[] = []; + const fungibleIndices: number[] = []; + + for (const [index, assetId] of assetIds.entries()) { + if (isSnapOwnedAsset(assetId)) { + result[index] = await this.#snapAdapter.getAccountAssetByID( + accountId, + assetId, + ); + } else { + fungibleIds.push(assetId); + fungibleIndices.push(index); + } + } + + if (fungibleIds.length === 0) { + return result; + } + + const fungibleResults = await this.#getProviderAccountAssetsByIDs( + accountId, + fungibleIds, + ); + + fungibleIds.forEach((assetId, fungibleIndex) => { + const resultIndex = fungibleIndices[fungibleIndex]; + if (resultIndex !== undefined) { + result[resultIndex] = fungibleResults[assetId] ?? null; + } + }); + + return result; + } + + async getAccountAssetsByScope( + scope: Network, + accountId: string, + ): Promise { + const snapAssets = await this.#snapAdapter.getAccountAssetsByScope( + scope, + accountId, + ); + const snapOwnedAssets = snapAssets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); + const coreAssets = await this.#getProviderAccountAssetsByScope( + scope, + accountId, + ); + + return [ + ...coreAssets.filter((asset) => !isSnapOwnedAsset(asset.assetType)), + ...snapOwnedAssets, + ]; + } + + async getByKeyringAccountId(accountId: string): Promise { + const assets = await this.#snapAdapter.getAccountAssetsByScope( + Network.Mainnet, + accountId, + ); + + return assets.filter((asset) => isSnapOwnedAsset(asset.assetType)); } async fetchAssetsAndBalancesForAccount( @@ -94,12 +220,6 @@ export class AssetsService { return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); } - async getAssetsMetadata( - assetTypes: CaipAssetType[], - ): Promise> { - return this.#snapAdapter.getAssetsMetadata(assetTypes); - } - async saveMany(assets: AssetEntity[]): Promise { return this.#snapAdapter.saveMany(assets); } @@ -108,8 +228,15 @@ export class AssetsService { return this.#snapAdapter.getAll(); } - async getByKeyringAccountId(accountId: string): Promise { - return this.#snapAdapter.getByKeyringAccountId(accountId); + async getHistoricalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise<{ + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }> { + return this.#snapAdapter.getHistoricalPrice(from, to); } async getMultipleTokenConversions( @@ -120,6 +247,12 @@ export class AssetsService { return this.#snapAdapter.getMultipleTokenConversions(conversions); } + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + return this.#snapAdapter.getAssetsMetadata(assetTypes); + } + async getMultipleTokensMarketData( assets: { asset: CaipAssetType; @@ -130,15 +263,4 @@ export class AssetsService { > { return this.#snapAdapter.getMultipleTokensMarketData(assets); } - - async getHistoricalPrice( - from: CaipAssetType, - to: CaipAssetType, - ): Promise<{ - intervals: HistoricalPriceIntervals; - updateTime: number; - expirationTime?: number; - }> { - return this.#snapAdapter.getHistoricalPrice(from, to); - } } 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 0590aa2ce..e8d8a8896 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -61,6 +61,7 @@ import { createPrefixedLogger } from '../../../utils/logger'; import type { ILogger } from '../../../utils/logger'; import type { State, UnencryptedStateValue } from '../../state/State'; import type { AssetsRepository } from '../AssetsRepository'; +import { isSnapOwnedAsset } from '../snapOwnedAssets'; import type { InLockPeriodCaipAssetType, NativeCaipAssetType, @@ -155,55 +156,51 @@ export class SnapAssetsAdapter { return caipAssetId.includes('swift:0/iso4217:'); } - async getAccountAssets(accountId: string): Promise { - return this.#assetsRepository.getByAccountId(accountId); - } - - async getAccountAssetsByIDs( + async getAccountAssetByID( accountId: string, - assetTypes: string[], - ): Promise<(AssetEntity | null)[]> { - return this.#assetsRepository.getByAccountIdAndAssetTypes( + assetId: string, + ): Promise { + return this.#assetsRepository.getByAccountIdAndAssetType( accountId, - assetTypes, + assetId, ); } - async getAccountAssetByID( + async getAccountAssetsByIDs( accountId: string, - assetType: string, - ): Promise { - return this.#assetsRepository.getByAccountIdAndAssetType( + assetIds: string[], + ): Promise> { + const assets = await this.#assetsRepository.getByAccountIdAndAssetTypes( accountId, - assetType, + assetIds, + ); + + return Object.fromEntries( + assetIds.map((assetId, index) => [assetId, assets[index] ?? null]), ); } /** - * Fetches all assets and balances for an account. + * Fetches snap-owned protocol assets and balances for an account. + * + * Fungible balances (TRX, TRC10, TRC20) are owned by Core AssetsController. + * This method only syncs Snap-managed protocol assets: energy, bandwidth, + * staking positions, lock/withdrawal, and rewards. * * Data Sources: - * - `getAccountInfoByAddress`: TRX balance, TRC10 tokens, TRC20 tokens (active accounts only) + * - `getAccountInfoByAddress`: Staking data (active accounts only) * - `getAccountResources`: Energy and Bandwidth (returns {} for inactive accounts) - * - `getTrc20BalancesByAddress`: TRC20 balances fallback (works for inactive accounts) - * - * Logic Flow: - * 1. Fetch account info, resources, and TRC20 fallback (for inactive accounts) - * 2. Normalize data into consistent shape via `#buildAccountData` - * 3. Extract all assets via `#extractAssets` - * 4. Fetch metadata and prices in parallel - * 5. Enrich assets with metadata via `#enrichAssetsWithMetadata` - * 6. Filter spam tokens via `#filterTokensWithoutPriceData` + * - `getReward`: Unclaimed staking rewards * * @param scope - The network to query. * @param account - The keyring account. - * @returns Promise - Array of assets with balances. + * @returns Promise - Array of snap-owned assets with balances. */ async fetchAssetsAndBalancesForAccount( scope: Network, account: KeyringAccount, ): Promise { - this.#logger.info('Fetching assets and balances by account', { + this.#logger.info('Fetching snap-owned assets and balances by account', { account, scope, }); @@ -218,36 +215,21 @@ export class SnapAssetsAdapter { this.#tronHttpClient.getReward(scope, account.address), ]); - const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - if (isInactiveAccount) { + if (tronAccountInfoRequest.status === 'rejected') { this.#logger.info( 'Account info request failed, treating as inactive account', { account, scope }, ); } - const trc20BalancesFallback = isInactiveAccount - ? await this.#trongridApiClient - .getTrc20BalancesByAddress(scope, account.address) - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - 'Failed to fetch TRC20 balances for inactive account', - { error, account, scope }, - ); - return []; - }) - : []; - const accountData = this.#buildAccountData({ tronAccountInfoRequest, tronAccountResourcesRequest, - trc20BalancesFallback, + trc20BalancesFallback: [], stakingRewardsRequest, }); - const rawAssets = this.#extractAssets(account, scope, accountData); - + const rawAssets = this.#extractSnapOwnedAssets(account, scope, accountData); const assetTypes = rawAssets.map((asset) => asset.assetType); const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); @@ -355,28 +337,16 @@ export class SnapAssetsAdapter { }; } - /** - * Extracts all assets from normalized account data. - * Coordinates calls to individual extraction functions. - * - * @param account - The keyring account. - * @param scope - The network. - * @param data - Normalized account data. - * @returns AssetEntity[] - Array of all extracted assets. - */ - #extractAssets( + #extractSnapOwnedAssets( account: KeyringAccount, scope: Network, data: NormalizedAccountData, ): AssetEntity[] { return [ - this.#extractNativeAsset(account, scope, data.nativeBalance), ...this.#extractStakedNativeAssets(account, scope, data.stakedData), this.#extractReadyForWithdrawalAsset(account, scope, data.stakedData), this.#extractInLockPeriodAsset(account, scope, data.stakedData), this.#extractStakingRewardsAsset(account, scope, data.stakingRewards), - ...this.#extractTrc10Assets(account, scope, data.trc10Balances), - ...this.#extractTrc20Assets(account, scope, data.trc20Balances), ...this.#extractBandwidth({ account, scope, @@ -456,34 +426,6 @@ export class SnapAssetsAdapter { }); } - /** - * Extracts the native TRX asset from the balance. - * - * @param account - The keyring account. - * @param scope - The network. - * @param balance - The native balance in sun. - * @returns AssetEntity - The native TRX asset. - */ - #extractNativeAsset( - account: KeyringAccount, - scope: Network, - balance: number, - ): AssetEntity { - return { - assetType: Networks[scope].nativeToken.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].nativeToken.symbol, - decimals: Networks[scope].nativeToken.decimals, - rawAmount: balance.toString(), - uiAmount: toUiAmount( - balance, - Networks[scope].nativeToken.decimals, - ).toString(), - iconUrl: Networks[scope].nativeToken.iconUrl, - }; - } - /** * Extracts staked TRX assets (for bandwidth and energy). * @@ -763,66 +705,6 @@ export class SnapAssetsAdapter { ]; } - /** - * Extracts TRC10 assets from the balances array. - * - * @param account - The keyring account. - * @param scope - The network. - * @param trc10Balances - TRC10 token balances as `{ key: tokenId, value: balance }[]`. - * @returns AssetEntity[] - Array of TRC10 asset entities. - */ - #extractTrc10Assets( - account: KeyringAccount, - scope: Network, - trc10Balances: TronAccount['assetV2'], - ): AssetEntity[] { - return ( - trc10Balances?.flatMap((tokenObject) => { - // assetV2 has structure: { "key": "token_id", "value": "balance" } - return { - assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: tokenObject.value?.toString() ?? '0', - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }) ?? [] - ); - } - - /** - * Extracts TRC20 assets from a balances array. - * Works with both active accounts (tronAccountInfo.trc20) and inactive accounts (getTrc20BalancesByAddress). - * - * @param account - The keyring account. - * @param scope - The network. - * @param trc20Balances - Array of `Record` objects (e.g., `[{ "TContractAddr": "1000" }]`). - * @returns AssetEntity[] - Array of TRC20 asset entities. - */ - #extractTrc20Assets( - account: KeyringAccount, - scope: Network, - trc20Balances: Trc20Balance[], - ): AssetEntity[] { - return trc20Balances.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }); - }); - } - async getAssetsMetadata( assetTypes: CaipAssetType[], ): Promise> { @@ -1261,12 +1143,14 @@ export class SnapAssetsAdapter { async saveMany(assets: AssetEntity[]): Promise { this.#logger.info('Saving assets', assets); + // Core owns fungibles; only persist/emit snap-owned protocol assets. + const shouldEmitAsset = (asset: AssetEntity): boolean => + isSnapOwnedAsset(asset.assetType); + const hasZeroAmount = (asset: AssetEntity): boolean => asset.rawAmount === '0' || asset.uiAmount === '0'; const savedAssets = await this.getAll(); - const isEssentialAsset = (asset: AssetEntity): boolean => - ESSENTIAL_ASSETS.includes(asset.assetType); // Track only the account/network pairs refreshed in this run. // That prevents us from treating assets from untouched networks as disappeared. @@ -1283,17 +1167,18 @@ export class SnapAssetsAdapter { assets.map((asset) => `${asset.keyringAccountId}:${asset.assetType}`), ); - // A saved asset is considered disappeared only if its network was part of - // this sync, it is not essential, and it is missing from the latest - // snapshot for that account. + // A saved snap-owned asset is considered disappeared only if its network was + // part of this sync and it is missing from the latest snapshot. Fungibles are + // ignored because Core owns them. const disappearedAssets = savedAssets.filter((savedAsset) => { const syncedNetworks = syncedNetworksByAccount[savedAsset.keyringAccountId]; - if ( - !syncedNetworks?.has(savedAsset.network) || - isEssentialAsset(savedAsset) - ) { + if (!syncedNetworks?.has(savedAsset.network)) { + return false; + } + + if (!isSnapOwnedAsset(savedAsset.assetType)) { return false; } @@ -1303,10 +1188,10 @@ export class SnapAssetsAdapter { }); // A token should be removed from the visible asset list only when the latest - // snapshot says its balance is zero. Essential assets stay visible even at - // zero because they are part of the permanent Tron account model. + // snapshot says its balance is zero. Snap-owned protocol assets stay visible + // even at zero because they are part of the permanent Tron account model. const shouldBeInRemovedList = (asset: AssetEntity): boolean => - hasZeroAmount(asset) && !isEssentialAsset(asset); // Never remove essential assets (including energy & bandwidth) from the account asset list + hasZeroAmount(asset) && !isSnapOwnedAsset(asset.assetType); // Assets are added to the visible list when they are non-zero and either: // - we are doing a full non-incremental broadcast, or @@ -1320,23 +1205,23 @@ export class SnapAssetsAdapter { // snapshot entirely // 2. fold in the current assets to report additions and explicit zero-balance // removals in the same event - const assetListUpdatedPayload = disappearedAssets.reduce< - AccountAssetListUpdatedEvent['params']['assets'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [...(acc[asset.keyringAccountId]?.added ?? [])], - removed: [ - ...(acc[asset.keyringAccountId]?.removed ?? []), - asset.assetType, - ], - }, - }), - {}, - ); + const assetListUpdatedPayload = disappearedAssets + .filter(shouldEmitAsset) + .reduce( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [...(acc[asset.keyringAccountId]?.added ?? [])], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + asset.assetType, + ], + }, + }), + {}, + ); - for (const asset of assets) { + for (const asset of assets.filter(shouldEmitAsset)) { // Merge the current snapshot into the pre-seeded payload so each account // ends up with one consolidated added/removed diff. assetListUpdatedPayload[asset.keyringAccountId] = { @@ -1367,21 +1252,27 @@ export class SnapAssetsAdapter { // Emit synthetic zero-balance entries for disappeared assets so clients can // clear cached balances even when the backend omits zero-balance tokens // instead of returning them explicitly. - const removedAssetsWithZeroBalance = disappearedAssets.map((asset) => ({ - ...asset, - rawAmount: '0', - uiAmount: '0', - })); + const removedAssetsWithZeroBalance = disappearedAssets + .filter(shouldEmitAsset) + .map((asset) => ({ + ...asset, + rawAmount: '0', + uiAmount: '0', + })); - const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; + const assetsToSave = [ + ...assets.filter(shouldEmitAsset), + ...removedAssetsWithZeroBalance, + ]; // Save assets using repository await this.#assetsRepository.saveMany(assetsToSave); // Broadcast the current snapshot plus synthetic zero-balance removals so the // client can reconcile both visible assets and cached balances in one pass. - const balancesUpdatedPayload = assetsToSave.reduce< - AccountBalancesUpdatedEvent['params']['balances'] - >( + const balancesUpdatedPayload = [ + ...assets.filter(shouldEmitAsset), + ...removedAssetsWithZeroBalance, + ].reduce( (acc, asset) => ({ ...acc, [asset.keyringAccountId]: { @@ -1442,19 +1333,26 @@ export class SnapAssetsAdapter { } as AssetEntity; } - async getByKeyringAccountId( + async getAccountAssetsByScope( + scope: Network, keyringAccountId: string, ): Promise { const savedAssets = await this.#assetsRepository.getByAccountId(keyringAccountId); - /** - * Ensure the special assets are always present whether they have been synced or not. - * These are assets that should be visible to the user even with zero balance. - */ + const visibleSavedAssets = savedAssets.filter( + (asset) => asset.network === scope, + ); + const missingEssentialAssets: AssetEntity[] = []; for (const essentialAssetId of ESSENTIAL_ASSETS) { + const { chainId } = parseCaipAssetType(essentialAssetId as CaipAssetType); + + if ((chainId as Network) !== scope) { + continue; + } + const savedAsset = savedAssets.find( (asset) => (asset.assetType as string) === essentialAssetId, ); @@ -1468,7 +1366,7 @@ export class SnapAssetsAdapter { } } - return [...savedAssets, ...missingEssentialAssets]; + return [...visibleSavedAssets, ...missingEssentialAssets]; } /** diff --git a/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts new file mode 100644 index 000000000..852b65344 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts @@ -0,0 +1,84 @@ +import type { Asset } from '@metamask/assets-controller'; +import type { CaipAssetType } from '@metamask/utils'; + +import { KnownCaip19Id, Network, TokenMetadata } from '../../constants'; +import { mapControllerAsset } from './mapControllerAsset'; + +describe('mapControllerAsset', () => { + const accountId = 'account-id'; + const knownAssetId = KnownCaip19Id.TrxMainnet; + const unknownAssetId = 'tron:728126428/trc20:unknown'; + + it('maps controller metadata when present', () => { + const asset = { + id: unknownAssetId, + chainId: Network.Mainnet, + balance: { amount: '1234567' }, + metadata: { + type: 'fungible', + symbol: 'TKN', + name: 'Token', + decimals: 6, + image: 'https://example.com/token.png', + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: unknownAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TKN', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + iconUrl: 'https://example.com/token.png', + }); + }); + + it('falls back to TokenMetadata when controller metadata is missing', () => { + const asset = { + id: knownAssetId, + chainId: Network.Mainnet, + balance: { amount: '2000000' }, + metadata: { type: 'fungible', name: 'TRON' }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: knownAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: TokenMetadata[knownAssetId].symbol, + decimals: TokenMetadata[knownAssetId].decimals, + rawAmount: '2000000', + uiAmount: '2', + iconUrl: TokenMetadata[knownAssetId].iconUrl, + }); + }); + + it('uses empty defaults when metadata is missing everywhere', () => { + const assetId = 'tron:728126428/trc20:missing' as CaipAssetType; + const asset = { + id: assetId, + chainId: Network.Mainnet, + balance: { amount: '42' }, + metadata: { type: 'fungible', name: 'Missing' }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: assetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: '', + decimals: 0, + rawAmount: '42', + uiAmount: '42', + iconUrl: '', + }); + }); +}); diff --git a/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts new file mode 100644 index 000000000..6b09e4888 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts @@ -0,0 +1,35 @@ +import type { Asset } from '@metamask/assets-controller'; + +import { Network, TokenMetadata } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import { toUiAmount } from '../../utils/conversion'; + +/** + * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. + * + * @param accountId - Keyring account ID. + * @param asset - Asset returned by AssetsController. + * @returns Mapped asset entity. + */ +export function mapControllerAsset( + accountId: string, + asset: Asset, +): AssetEntity { + const assetId = asset.id; + const knownMetadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; + const decimals = asset.metadata.decimals ?? knownMetadata?.decimals ?? 0; + const symbol = asset.metadata.symbol ?? knownMetadata?.symbol ?? ''; + const iconUrl = asset.metadata.image ?? knownMetadata?.iconUrl ?? ''; + const { amount } = asset.balance; + + return { + assetType: assetId, + keyringAccountId: accountId, + network: asset.chainId as Network, + symbol, + decimals, + rawAmount: amount, + uiAmount: toUiAmount(amount, decimals).toString(), + iconUrl, + } as AssetEntity; +} diff --git a/packages/tron-wallet-snap/src/types/core-messenger.ts b/packages/tron-wallet-snap/src/types/core-messenger.ts new file mode 100644 index 000000000..258d0752b --- /dev/null +++ b/packages/tron-wallet-snap/src/types/core-messenger.ts @@ -0,0 +1,36 @@ +import type { + AssetsControllerGetAccountAssetByIDAction, + AssetsControllerGetAccountAssetsByIDsAction, + AssetsControllerGetAccountAssetsByScopeAction, +} from '@metamask/assets-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AsyncMessenger } from '@metamask/snaps-sdk'; + +/** + * Namespace for this Snap's Core messenger endowment. + */ +export const TRON_WALLET_SNAP_MESSENGER_NAMESPACE = 'TronWalletSnap' as const; + +export type CoreMessengerActions = + | AssetsControllerGetAccountAssetByIDAction + | AssetsControllerGetAccountAssetsByIDsAction + | AssetsControllerGetAccountAssetsByScopeAction; + +/** + * Messenger type passed to `getMessenger` for Core controller actions. + */ +export type CoreMessengerMessenger = Messenger< + typeof TRON_WALLET_SNAP_MESSENGER_NAMESPACE, + CoreMessengerActions +>; + +/** + * Typed async messenger for Core controller actions available to this Snap via + * `endowment:messenger` / `getMessenger`. + */ +export type CoreMessenger = AsyncMessenger; + +/** + * Narrow dependency for services that only need to invoke Core actions. + */ +export type CoreMessengerCaller = Pick; diff --git a/yarn.lock b/yarn.lock index 14ac741c6..8a6f1fd65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3312,7 +3312,7 @@ __metadata: languageName: node linkType: hard -"@metamask/snap-networks-utils@workspace:packages/snap-networks-utils": +"@metamask/snap-networks-utils@npm:^1.0.0, @metamask/snap-networks-utils@workspace:packages/snap-networks-utils": version: 0.0.0-use.local resolution: "@metamask/snap-networks-utils@workspace:packages/snap-networks-utils" dependencies: @@ -3780,14 +3780,18 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/tron-wallet-snap@workspace:packages/tron-wallet-snap" dependencies: + "@metamask/assets-controller": "npm:^13.0.0" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^23.7.0" "@metamask/keyring-snap-sdk": "npm:^9.2.1" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/snap-networks-utils": "npm:^1.0.0" "@metamask/snaps-cli": "npm:^8.4.1" "@metamask/snaps-jest": "npm:^10.2.0" "@metamask/snaps-sdk": "npm:^11.2.0" "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^11.9.0" "@types/lodash": "npm:^4.17.15" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.3.1"