From a45cff35894d78e23a98337106bb0ffdad35e97d Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 15 Jul 2025 09:44:20 +0100 Subject: [PATCH 001/238] feat: initial commit --- .../tron-wallet-snap/.depcheckrc.json | 3 + merged-packages/tron-wallet-snap/.env.example | 20 + .../tron-wallet-snap/.prettierignore | 2 + merged-packages/tron-wallet-snap/CHANGELOG.md | 427 ++++++++++ merged-packages/tron-wallet-snap/README.md | 28 + .../tron-wallet-snap/babel.config.js | 6 + .../tron-wallet-snap/images/icon.svg | 11 + .../tron-wallet-snap/jest.config.js | 14 + .../jest.integration.config.js | 11 + .../tron-wallet-snap/messages.json | 146 ++++ merged-packages/tron-wallet-snap/package.json | 65 ++ .../scripts/build-preinstalled-snap.js | 77 ++ .../scripts/populate-en-locale.js | 26 + .../tron-wallet-snap/snap.config.ts | 29 + .../tron-wallet-snap/snap.manifest.json | 79 ++ .../tron-wallet-snap/src/caching/ICache.ts | 87 ++ .../src/caching/InMemoryCache.ts | 182 ++++ .../src/caching/StateCache.test.ts | 781 ++++++++++++++++++ .../src/caching/StateCache.ts | 257 ++++++ .../tron-wallet-snap/src/caching/types.ts | 11 + .../src/caching/useCache.test.ts | 245 ++++++ .../tron-wallet-snap/src/caching/useCache.ts | 86 ++ .../clients/price-api/PriceApiClient.test.ts | 384 +++++++++ .../src/clients/price-api/PriceApiClient.ts | 320 +++++++ .../price-api/mocks/historical-prices.ts | 17 + .../clients/price-api/mocks/spot-prices.ts | 163 ++++ .../src/clients/price-api/structs.test.ts | 29 + .../src/clients/price-api/types.ts | 228 +++++ .../tron-wallet-snap/src/constants/tron.ts | 65 ++ .../tron-wallet-snap/src/context.ts | 106 +++ .../tron-wallet-snap/src/entities/index.ts | 1 + .../src/entities/keyring-account.ts | 31 + .../tron-wallet-snap/src/handlers/assets.ts | 41 + .../tron-wallet-snap/src/handlers/cronjob.ts | 14 + .../tron-wallet-snap/src/handlers/keyring.ts | 322 ++++++++ .../tron-wallet-snap/src/handlers/rpc.ts | 18 + .../src/handlers/userInput.ts | 18 + merged-packages/tron-wallet-snap/src/index.ts | 42 + .../tron-wallet-snap/src/permissions.ts | 46 ++ .../src/services/assets/AssetsService.ts | 149 ++++ .../src/services/assets/types.ts | 30 + .../src/services/config/ConfigProvider.ts | 229 +++++ .../src/services/config/index.ts | 1 + .../src/services/state/IStateManager.ts | 85 ++ .../src/services/state/InMemoryState.ts | 46 ++ .../src/services/state/State.test.ts | 378 +++++++++ .../src/services/state/State.ts | 133 +++ .../transactions/TransactionsService.ts | 21 + .../src/services/wallet/WalletService.ts | 25 + .../tron-wallet-snap/src/types/snap.ts | 22 + .../src/utils/buildUrl.test.ts | 118 +++ .../tron-wallet-snap/src/utils/buildUrl.ts | 53 ++ .../src/utils/deriveTronKeypair.ts | 70 ++ .../tron-wallet-snap/src/utils/errors.test.ts | 178 ++++ .../tron-wallet-snap/src/utils/errors.ts | 70 ++ .../src/utils/getBip32Entropy.test.ts | 58 ++ .../src/utils/getBip32Entropy.ts | 32 + .../src/utils/getLowestUnusedIndex.test.ts | 60 ++ .../src/utils/getLowestUnusedIndex.ts | 42 + .../tron-wallet-snap/src/utils/i18n.test.ts | 30 + .../tron-wallet-snap/src/utils/i18n.ts | 37 + .../tron-wallet-snap/src/utils/interface.ts | 125 +++ .../tron-wallet-snap/src/utils/logger.ts | 50 ++ .../src/utils/safeMerge.test.ts | 119 +++ .../tron-wallet-snap/src/utils/safeMerge.ts | 28 + .../utils/serialization/deserialize.test.ts | 95 +++ .../src/utils/serialization/deserialize.ts | 36 + .../src/utils/serialization/serialize.test.ts | 107 +++ .../src/utils/serialization/serialize.ts | 47 ++ .../src/utils/serialization/types.ts | 17 + .../src/validation/structs.test.ts | 243 ++++++ .../src/validation/structs.ts | 309 +++++++ .../src/validation/validators.ts | 57 ++ .../tron-wallet-snap/tsconfig.json | 11 + 74 files changed, 7549 insertions(+) create mode 100644 merged-packages/tron-wallet-snap/.depcheckrc.json create mode 100644 merged-packages/tron-wallet-snap/.env.example create mode 100644 merged-packages/tron-wallet-snap/.prettierignore create mode 100644 merged-packages/tron-wallet-snap/CHANGELOG.md create mode 100644 merged-packages/tron-wallet-snap/README.md create mode 100644 merged-packages/tron-wallet-snap/babel.config.js create mode 100644 merged-packages/tron-wallet-snap/images/icon.svg create mode 100644 merged-packages/tron-wallet-snap/jest.config.js create mode 100644 merged-packages/tron-wallet-snap/jest.integration.config.js create mode 100644 merged-packages/tron-wallet-snap/messages.json create mode 100644 merged-packages/tron-wallet-snap/package.json create mode 100644 merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js create mode 100644 merged-packages/tron-wallet-snap/scripts/populate-en-locale.js create mode 100644 merged-packages/tron-wallet-snap/snap.config.ts create mode 100644 merged-packages/tron-wallet-snap/snap.manifest.json create mode 100644 merged-packages/tron-wallet-snap/src/caching/ICache.ts create mode 100644 merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts create mode 100644 merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/caching/StateCache.ts create mode 100644 merged-packages/tron-wallet-snap/src/caching/types.ts create mode 100644 merged-packages/tron-wallet-snap/src/caching/useCache.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/caching/useCache.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/price-api/mocks/historical-prices.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/price-api/mocks/spot-prices.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/price-api/structs.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/price-api/types.ts create mode 100644 merged-packages/tron-wallet-snap/src/constants/tron.ts create mode 100644 merged-packages/tron-wallet-snap/src/context.ts create mode 100644 merged-packages/tron-wallet-snap/src/entities/index.ts create mode 100644 merged-packages/tron-wallet-snap/src/entities/keyring-account.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/assets.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/cronjob.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/keyring.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/rpc.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/userInput.ts create mode 100644 merged-packages/tron-wallet-snap/src/index.ts create mode 100644 merged-packages/tron-wallet-snap/src/permissions.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/assets/types.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/config/index.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/state/IStateManager.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/state/State.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/state/State.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts create mode 100644 merged-packages/tron-wallet-snap/src/types/snap.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/buildUrl.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/buildUrl.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/errors.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/errors.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/i18n.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/i18n.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/interface.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/logger.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/safeMerge.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/safeMerge.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/serialization/types.ts create mode 100644 merged-packages/tron-wallet-snap/src/validation/structs.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/validation/structs.ts create mode 100644 merged-packages/tron-wallet-snap/src/validation/validators.ts create mode 100644 merged-packages/tron-wallet-snap/tsconfig.json diff --git a/merged-packages/tron-wallet-snap/.depcheckrc.json b/merged-packages/tron-wallet-snap/.depcheckrc.json new file mode 100644 index 00000000..836108a8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/.depcheckrc.json @@ -0,0 +1,3 @@ +{ + "ignores": ["jest-transform-stub", "ts-jest"] +} diff --git a/merged-packages/tron-wallet-snap/.env.example b/merged-packages/tron-wallet-snap/.env.example new file mode 100644 index 00000000..787de2a6 --- /dev/null +++ b/merged-packages/tron-wallet-snap/.env.example @@ -0,0 +1,20 @@ +# Use: +# - local for local development +# - test for running tests locally (mandatory) +# - production before submitting a PR +ENVIRONMENT=local + +RPC_URL_MAINNET=https://api.trongrid.io +RPC_URL_SHASTA_TESTNET=https://api.shasta.trongrid.io/jsonrpc +RPC_URL_NILE_TESTNET=https://nile.trongrid.io +RPC_URL_LOCALNET=http://localhost:8899 + +EXPLORER_BASE_URL=https://tronscan.org + +PRICE_API_BASE_URL=https://price.api.cx.metamask.io +TOKEN_API_BASE_URL=https://tokens.api.cx.metamask.io +STATIC_API_BASE_URL=https://static.cx.metamask.io +SECURITY_ALERTS_API_BASE_URL=https://security-alerts.api.cx.metamask.io +NFT_API_BASE_URL=https://nft.api.cx.metamask.io + +LOCAL_API_BASE_URL=http://localhost:8899 \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/.prettierignore b/merged-packages/tron-wallet-snap/.prettierignore new file mode 100644 index 00000000..a60030e3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/.prettierignore @@ -0,0 +1,2 @@ +dist/ +coverage/ diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md new file mode 100644 index 00000000..15176ff5 --- /dev/null +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -0,0 +1,427 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.17.0] + +### Changed + +- Synchronize by default ([#492](https://github.com/MetaMask/snap-bitcoin-wallet/pull/492)) + +### Fixed + +- Synchronize accounts with no history ([#491](https://github.com/MetaMask/snap-bitcoin-wallet/pull/491)) + +## [0.16.1] + +### Fixed + +- Typo `onAssetsMarketData` ([#489](https://github.com/MetaMask/snap-bitcoin-wallet/pull/489)) + +## [0.16.0] + +### Added + +- Asset market data on new handler ([#486](https://github.com/MetaMask/snap-bitcoin-wallet/pull/486)) +- Send flow error handling ([#482](https://github.com/MetaMask/snap-bitcoin-wallet/pull/482)) +- Translations ([#483](https://github.com/MetaMask/snap-bitcoin-wallet/pull/483)) + +### Changed + +- Use event value on user input instead of fetching the form state ([#485](https://github.com/MetaMask/snap-bitcoin-wallet/pull/485)) + +## [0.15.0] + +### Added + +- Account index auto increment on creation ([#471](https://github.com/MetaMask/snap-bitcoin-wallet/pull/471)) +- Account selector in send flow ([#479](https://github.com/MetaMask/snap-bitcoin-wallet/pull/479)) +- Market data ([#478](https://github.com/MetaMask/snap-bitcoin-wallet/pull/478)) +- Switch currencies in send flow ([#477](https://github.com/MetaMask/snap-bitcoin-wallet/pull/477)) + +### Changed + +- Align Send flow ([#472](https://github.com/MetaMask/snap-bitcoin-wallet/pull/472)) + +### Fixed + +- Entropy source as part of the `KeyringAccount` options ([#473](https://github.com/MetaMask/snap-bitcoin-wallet/pull/473)) +- Delete account keeping residue in state ([#469](https://github.com/MetaMask/snap-bitcoin-wallet/pull/469)) + +## [0.14.1] + +### Fixed + +- Account name passthrough ([#466](https://github.com/MetaMask/snap-bitcoin-wallet/pull/466)) +- Prevent account deletion key residues ([#466](https://github.com/MetaMask/snap-bitcoin-wallet/pull/466)) + +## [0.14.0] + +### Added + +- Discover accounts ([#464](https://github.com/MetaMask/snap-bitcoin-wallet/pull/464), [#460](https://github.com/MetaMask/snap-bitcoin-wallet/pull/460)) +- Use address types from `keyring-api` ([#461](https://github.com/MetaMask/snap-bitcoin-wallet/pull/461)) + +### Changed + +- Development environment cleanup ([#459](https://github.com/MetaMask/snap-bitcoin-wallet/pull/459)) +- Use `setState` and `getState` instead of `manageState` ([#463](https://github.com/MetaMask/snap-bitcoin-wallet/pull/463)) + +## [0.13.0] + +### Added + +- Multi account ([#457](https://github.com/MetaMask/snap-bitcoin-wallet/pull/457)) +- Historical prices ([#456](https://github.com/MetaMask/snap-bitcoin-wallet/pull/456)) + +## [0.12.1] + +### Added + +- Add unconfirmed transactions to wallet history ([#453](https://github.com/MetaMask/snap-bitcoin-wallet/pull/453)) + +### Changed + +- Reduce bundle size ([#454](https://github.com/MetaMask/snap-bitcoin-wallet/pull/454)) + +## [0.12.0] + +### Added + +- Compress preinstalled Snap during build ([#451](https://github.com/MetaMask/snap-bitcoin-wallet/pull/451)) + +### Changed + +- Disable UTXO protection ([#445](https://github.com/MetaMask/snap-bitcoin-wallet/pull/445)) +- Generate PSBT in send flow ([#448](https://github.com/MetaMask/snap-bitcoin-wallet/pull/448)) + +### Removed + +- Remove SimpleHash ([#447](https://github.com/MetaMask/snap-bitcoin-wallet/pull/447)) + +## [0.11.0] + +### Added + +- Support for `correlationId` and `entropySource` ([#443](https://github.com/MetaMask/snap-bitcoin-wallet/pull/443)) + +### Changed + +- Refactor error handling ([#442](https://github.com/MetaMask/snap-bitcoin-wallet/pull/442)) +- Refactor locale ([#433](https://github.com/MetaMask/snap-bitcoin-wallet/pull/433)) +- Refactor logger ([#437](https://github.com/MetaMask/snap-bitcoin-wallet/pull/437)) +- Reduce bundle size ([#439](https://github.com/MetaMask/snap-bitcoin-wallet/pull/439)) + +### Fixed + +- Discard own outputs from send transactions ([#441](https://github.com/MetaMask/snap-bitcoin-wallet/pull/441)) + +## [0.10.0] + +### Added + +- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408), [#427](https://github.com/MetaMask/snap-bitcoin-wallet/pull/427)) +- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) +- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) +- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) +- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) +- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) +- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) +- Translations ([#403]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) + +### Changed + +- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) +- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) + +### Removed + +- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) + +### Fixed + +- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) + +## [0.9.0] + +### Added + +- Add localized messages ([#348](https://github.com/MetaMask/snap-bitcoin-wallet/pull/348)) +- Add basic Sats protection support ([#337](https://github.com/MetaMask/snap-bitcoin-wallet/pull/337)), ([#284](https://github.com/MetaMask/snap-bitcoin-wallet/pull/284)), ([#349](https://github.com/MetaMask/snap-bitcoin-wallet/pull/349)) + - Using SimpleHash service. + +### Changed + +- **BREAKING:** Provide scopes field to `KeyringAccount` during account creation ([#364](https://github.com/MetaMask/snap-bitcoin-wallet/pull/364)) + - Bump `@metamask/keyring-api` from `^8.1.3` to `^13.0.0`. + - Compatible with `@metamask/eth-snap-keyring@^7.1.0`. +- Support for fee rate caching ([#358](https://github.com/MetaMask/snap-bitcoin-wallet/pull/358)) +- Use Snap UI update context instead of persisting request ([#345](https://github.com/MetaMask/snap-bitcoin-wallet/pull/345)) + - Making the Snap UI slighty more responsive and faster. +- Remove support of Blockchair service ([#298](https://github.com/MetaMask/snap-bitcoin-wallet/pull/298)) + +### Fixed + +- Various UI fixes ([#356](https://github.com/MetaMask/snap-bitcoin-wallet/pull/356)), ([#357](https://github.com/MetaMask/snap-bitcoin-wallet/pull/357)), ([#346](https://github.com/MetaMask/snap-bitcoin-wallet/pull/346)), ([#344](https://github.com/MetaMask/snap-bitcoin-wallet/pull/344)), ([#343](https://github.com/MetaMask/snap-bitcoin-wallet/pull/343)) +- Allow send to happen even when rates are not available ([#350](https://github.com/MetaMask/snap-bitcoin-wallet/pull/350)) + +## [0.8.2] + +### Fixed + +- Remove `localhost` from permissions ([#324](https://github.com/MetaMask/snap-bitcoin-wallet/pull/324)), ([#322](https://github.com/MetaMask/snap-bitcoin-wallet/pull/322)) + +## [0.8.1] + +### Fixed + +- Use `hideSnapBranding` flag when building ([#312](https://github.com/MetaMask/snap-bitcoin-wallet/pull/312)) + +## [0.8.0] + +### Added + +- Add send flow UI ([#281](https://github.com/MetaMask/snap-bitcoin-wallet/pull/281)), ([#309](https://github.com/MetaMask/snap-bitcoin-wallet/pull/309)), ([#308](https://github.com/MetaMask/snap-bitcoin-wallet/pull/308)), ([#305](https://github.com/MetaMask/snap-bitcoin-wallet/pull/305)), ([#304](https://github.com/MetaMask/snap-bitcoin-wallet/pull/304)), ([#289](https://github.com/MetaMask/snap-bitcoin-wallet/pull/289)) + - The send flow can be started using the `startSendTransactionFlow` internal method. + - The UI allows to send an amount to one recipient (according to its balance). + - There is a confirmation screen that summarize everything regarding the transaction (amount, recipient, fee estimation), once confirmed the transaction will be broadcasted to the blockchain. + - Calling the `sendBitcoin` account's method will trigger the confirmation screen too. + +### Changed + +- **BREAKING:** Rename `btc_sendmany` method to `sendBitcoin` ([#303](https://github.com/MetaMask/snap-bitcoin-wallet/pull/303)) + - The `comment` and `subtractFeeFrom` options have been removed too. +- **BREAKING:** Make transactions replaceable by default ([#297](https://github.com/MetaMask/snap-bitcoin-wallet/pull/297)) +- Rename proposed name to "Bitcoin" (was "Bitcoin Manager") ([#283](https://github.com/MetaMask/snap-bitcoin-wallet/pull/283)) +- Adds fee estimation fallback for `QuickNode` ([#269](https://github.com/MetaMask/snap-bitcoin-wallet/pull/269)) + - In some cases, `QuickNode` may fail to process fee estimation. In such instances, we fall back on using fee information directly from the mempool. + - We also added a default minimum fee as the last resort in case everything else failed. + +## [0.7.0] + +### Changed + +- Use `QuickNode` as the main provider ([#250](https://github.com/MetaMask/snap-bitcoin-wallet/pull/250)) +- Workaround `QuickNode` fee estimation for testnet ([#267](https://github.com/MetaMask/snap-bitcoin-wallet/pull/267)) + - We temporarily changed the confirmation target block to a higher block number to make sure the API is not failing with an error. + +### Fixed + +- Fix fee estimation with `QuickNode` ([#266](https://github.com/MetaMask/snap-bitcoin-wallet/pull/266)), ([#261](https://github.com/MetaMask/snap-bitcoin-wallet/pull/261)) + - Properly uses `kvB` instead of `vB`. + - Will **NOT** throw an error if the account has not enough UTXOs when estimating the fees. + +## [0.6.1] + +### Added + +- Add `QuickNode` API client ([#247](https://github.com/MetaMask/snap-bitcoin-wallet/pull/247)) + - This client is not yet used, we still use Blockchair provider for the moment. + +### Changed + +- Bump `@metamask/keyring-api` from `^8.0.2` to `^8.1.3` ([#253](https://github.com/MetaMask/snap-bitcoin-wallet/pull/253)) + - This version is now built slightly differently and is part of the [accounts monorepo](https://github.com/MetaMask/accounts). + +## [0.6.0] + +### Added + +- Display an alert dialog when an error happens during `btc_sendmany` ([#236](https://github.com/MetaMask/snap-bitcoin-wallet/pull/236)) + +### Changed + +- Use similar shorten-address format than the extension ([#238](https://github.com/MetaMask/snap-bitcoin-wallet/pull/238)) + +## [0.5.0] + +### Added + +- Add `getMaxSpendableBalance` RPC endpoint ([#188](https://github.com/MetaMask/snap-bitcoin-wallet/pull/188)) + +### Changed + +- Improve keyring tests coverage ([#220](https://github.com/MetaMask/snap-bitcoin-wallet/pull/220)) + +## [0.4.0] + +### Added + +- Emit account suggested name when creating account ([#210](https://github.com/MetaMask/snap-bitcoin-wallet/pull/210)) + - This name suggestion can then be used by the client to name the account accordingly. + +### Changed + +- Audit 4.6: Pin Bitcoin and cryptographic dependencies ([#205](https://github.com/MetaMask/snap-bitcoin-wallet/pull/205)) + +## [0.3.0] + +### Added + +- Add `estimateFee` RPC endpoint ([#169](https://github.com/MetaMask/snap-bitcoin-wallet/pull/169)) + +## [0.2.5] + +### Added + +- Add reusable error types ([#185](https://github.com/MetaMask/snap-bitcoin-wallet/pull/185)) + +### Changed + +- Use custom `superstruct` validator for `AmountStruct` ([#184](https://github.com/MetaMask/snap-bitcoin-wallet/pull/184)) + +### Fixed + +- Fix overridden message in `MethodNotFoundError` ([#189](https://github.com/MetaMask/snap-bitcoin-wallet/pull/189)) + +## [0.2.4] + +### Fixed + +- Audit 4.5: Show origin on `btc_sendmany` confirmation dialog ([#152](https://github.com/MetaMask/snap-bitcoin-wallet/pull/152)) +- Audit 4.7: Fix potential URL injections in Blockchair API calls ([#132](https://github.com/MetaMask/snap-bitcoin-wallet/pull/132)) +- Audit 4.11: Remove code for creating unsupported P2SHP2WPKH account type ([#118](https://github.com/MetaMask/snap-bitcoin-wallet/pull/118)) +- Audit 4.12: Derive accounts with different HD path by network ([#118](https://github.com/MetaMask/snap-bitcoin-wallet/pull/118)) +- Audit 4.19: Implement keyring method `filterAccountChains` ([#122](https://github.com/MetaMask/snap-bitcoin-wallet/pull/122)) + +## [0.2.3] + +### Changed + +- Change Snap name to "Bitcoin Manager" ([#158](https://github.com/MetaMask/snap-bitcoin-wallet/pull/158)) +- Change local dapp port ([#147](https://github.com/MetaMask/snap-bitcoin-wallet/pull/147)) + +### Fixed + +- Audit 4.2: Remove update account method ([#130](https://github.com/MetaMask/snap-bitcoin-wallet/pull/130)) +- Audit 4.3: Restrict permissions for Portfolio origin ([#131](https://github.com/MetaMask/snap-bitcoin-wallet/pull/131)) +- Audit 4.4: Restrict permissions for MetaMask origin ([#141](https://github.com/MetaMask/snap-bitcoin-wallet/pull/141)) +- Audit 4.8: Ensure that `request.method` in submit request is part of `account.methods` ([#133](https://github.com/MetaMask/snap-bitcoin-wallet/pull/133)) +- Audit 4.9: Validate non-hex string in `hexToBuffer` ([#134](https://github.com/MetaMask/snap-bitcoin-wallet/pull/134)) +- Audit 4.13: Add a safeguard for change output ([#135](https://github.com/MetaMask/snap-bitcoin-wallet/pull/135)) +- Audit 4.14: Rename `txHash` to `signedTransaction` ([#120](https://github.com/MetaMask/snap-bitcoin-wallet/pull/120)) +- Audit 4.20: Validate `headLength` and `tailLength` in `replaceMiddleChar` ([#138](https://github.com/MetaMask/snap-bitcoin-wallet/pull/138)) +- Audit 4.23: Disable logging for production builds ([#124](https://github.com/MetaMask/snap-bitcoin-wallet/pull/124)) +- Audit 4.24: Remove temporary create account endpoint ([#115](https://github.com/MetaMask/snap-bitcoin-wallet/pull/115)) + +## [0.2.2] + +### Changed + +- Remove unused env var ([#148](https://github.com/MetaMask/snap-bitcoin-wallet/pull/148)) + +### Fixed + +- Emit event on account creation ([#153](https://github.com/MetaMask/snap-bitcoin-wallet/pull/153)) + +## [0.2.1] + +### Fixed + +- Remove duplicate validation when getting balances ([#137](https://github.com/MetaMask/snap-bitcoin-wallet/pull/137)) + +## [0.2.0] + +### Added + +- Add 'ramps-dev.portfolio.metamask.io' origin ([#144](https://github.com/MetaMask/snap-bitcoin-wallet/pull/144)) +- Enable `getAccountBalances` method for Portfolio origins ([#126](https://github.com/MetaMask/snap-bitcoin-wallet/pull/126)) +- Implement Keyring API `getAccountBalances` method ([#84](https://github.com/MetaMask/snap-bitcoin-wallet/pull/84)) +- Implement Chain API `getTransactionStatus` method ([#85](https://github.com/MetaMask/snap-bitcoin-wallet/pull/85)) + +### Changed + +- Rename "Bitcoin Manager" to "Bitcoin Wallet" ([#142](https://github.com/MetaMask/snap-bitcoin-wallet/pull/142)) +- Improve `btc_sendMany` implementation ([#97](https://github.com/MetaMask/snap-bitcoin-wallet/pull/97)) +- Update `btc_sendMany` dialogs ([#83](https://github.com/MetaMask/snap-bitcoin-wallet/pull/83)) + +## [0.1.2] + +### Changed + +- fix: update change log format ([#76](https://github.com/MetaMask/bitcoin/pull/76)) +- fix: update package.json ([#76](https://github.com/MetaMask/bitcoin/pull/74)) + +## [0.1.1] + +### Added + +- fix: update resp of chainService - boardcastTransaction ([#68](https://github.com/MetaMask/bitcoin/pull/68)) +- feat: add auto install script ([#67](https://github.com/MetaMask/bitcoin/pull/67)) +- feat: add while list domain ([#71](https://github.com/MetaMask/bitcoin/pull/71)) +- chore: re structure ([#66](https://github.com/MetaMask/bitcoin/pull/66)) +- fix: change to keyring state method get wallet ([#62](https://github.com/MetaMask/bitcoin/pull/62)) +- feat: implement keyring api - btc_sendmany ([#65](https://github.com/MetaMask/bitcoin/pull/65)) +- chore: update snap provider instance name ([#69](https://github.com/MetaMask/bitcoin/pull/69)) +- build(deps-dev): bump @metamask/snaps-jest from 7.0.2 to 8.0.0 ([#50](https://github.com/MetaMask/bitcoin/pull/50)) +- feat: add bufferToString method ([#61](https://github.com/MetaMask/bitcoin/pull/61)) +- feat: add chain API - chain_broadcastTransaction ([#57](https://github.com/MetaMask/bitcoin/pull/57)) +- chore: add unit test for get balances ([#58](https://github.com/MetaMask/bitcoin/pull/58)) +- feat: add chain API - chain_getDataForTransaction ([#42](https://github.com/MetaMask/bitcoin/pull/42)) +- feat: add chain API - chain_estimateFees ([#18](https://github.com/MetaMask/bitcoin/pull/18)) +- feat: add keyring API - btc_sendmany skeleton ([#41](https://github.com/MetaMask/bitcoin/pull/41)) +- fix: update btc asset ([#44](https://github.com/MetaMask/bitcoin/pull/44)) +- feat: add ListAccountsButton card ([#43](https://github.com/MetaMask/bitcoin/pull/43)) +- chore: move config to factory ([#35](https://github.com/MetaMask/bitcoin/pull/35)) +- feat: add methods to support satoshi to btc, and btc to satoshi ([#34](https://github.com/MetaMask/bitcoin/pull/34)) +- chore: add commit method in state management ([#32](https://github.com/MetaMask/bitcoin/pull/32)) +- fix: restructure code ([#31](https://github.com/MetaMask/bitcoin/pull/31)) +- fix: remove non ready code ([#30](https://github.com/MetaMask/bitcoin/pull/30)) +- fix: fix snap publish package is not using builded snap config ([#29](https://github.com/MetaMask/bitcoin/pull/29)) +- feat: add api key for blockchair ([#27](https://github.com/MetaMask/bitcoin/pull/27)) +- chore: restructure the code repo to fit to chain api and keyring api structure ([#26](https://github.com/MetaMask/bitcoin/pull/26)) +- fix: refine coding structure ([#25](https://github.com/MetaMask/bitcoin/pull/25)) +- feat: support transactional state management ([#20](https://github.com/MetaMask/bitcoin/pull/20)) +- feat: add permission validation ([#24](https://github.com/MetaMask/bitcoin/pull/24)) +- fix: remove un use code in BaseSnapRpcHandler ([#19](https://github.com/MetaMask/bitcoin/pull/19)) +- feat: add validation on api response ([#17](https://github.com/MetaMask/bitcoin/pull/17)) +- feat: implement chain api - get balances ([#16](https://github.com/MetaMask/bitcoin/pull/16)) +- feat: setup init cd to publish snap to npm public registry ([#15](https://github.com/MetaMask/bitcoin/pull/15)) +- feat: implement chain api skeleton ([#14](https://github.com/MetaMask/bitcoin/pull/14)) +- feat: emit keyring event before state store ([#13](https://github.com/MetaMask/bitcoin/pull/13)) +- feat: implement keyring api ([#12](https://github.com/MetaMask/bitcoin/pull/12)) +- feat: add blockchair ([#11](https://github.com/MetaMask/bitcoin/pull/11)) +- chore: update keyring type and methods permission ([#10](https://github.com/MetaMask/bitcoin/pull/10)) +- chore: update snap icon ([#9](https://github.com/MetaMask/bitcoin/pull/9)) +- fix: the ci pipeline for metamask task issue ([#8](https://github.com/MetaMask/bitcoin/pull/8)) +- build(deps): bump @metamask/keyring-api from 5.1.0 to 6.0.0 ([#6](https://github.com/MetaMask/bitcoin/pull/6)) +- build(deps-dev): bump @metamask/snaps-jest from 6.0.2 to 7.0.2 ([#7](https://github.com/MetaMask/bitcoin/pull/7)) +- feat: add snap unit test ([#1](https://github.com/MetaMask/bitcoin/pull/1)) +- feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) +- feat: init commit + +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...HEAD +[0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 +[0.16.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...v0.16.1 +[0.16.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...v0.16.0 +[0.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...v0.15.0 +[0.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...v0.14.1 +[0.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...v0.14.0 +[0.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...v0.13.0 +[0.12.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...v0.12.1 +[0.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...v0.12.0 +[0.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...v0.11.0 +[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 +[0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 +[0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 +[0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...v0.8.0 +[0.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...v0.7.0 +[0.6.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...v0.6.1 +[0.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...v0.3.0 +[0.2.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...v0.2.5 +[0.2.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...v0.2.4 +[0.2.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.2...v0.2.0 +[0.1.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/MetaMask/snap-bitcoin-wallet/releases/tag/v0.1.1 diff --git a/merged-packages/tron-wallet-snap/README.md b/merged-packages/tron-wallet-snap/README.md new file mode 100644 index 00000000..cd8d4026 --- /dev/null +++ b/merged-packages/tron-wallet-snap/README.md @@ -0,0 +1,28 @@ +# Bitcoin Snap + +## Configuration + +Rename `.env.example` to `.env` +Configurations are setup though `.env`, + +## API: + +### `keyring_createAccount` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeKeyring', + params: { + snapId, + request: { + method: 'keyring_createAccount', + params: { + scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of the network + addressType: 'bip122:p2wpkh', // the CAIP-like address type + }, + }, + }, +}); +``` diff --git a/merged-packages/tron-wallet-snap/babel.config.js b/merged-packages/tron-wallet-snap/babel.config.js new file mode 100644 index 00000000..8165fe45 --- /dev/null +++ b/merged-packages/tron-wallet-snap/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], +}; diff --git a/merged-packages/tron-wallet-snap/images/icon.svg b/merged-packages/tron-wallet-snap/images/icon.svg new file mode 100644 index 00000000..5871de00 --- /dev/null +++ b/merged-packages/tron-wallet-snap/images/icon.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/merged-packages/tron-wallet-snap/jest.config.js b/merged-packages/tron-wallet-snap/jest.config.js new file mode 100644 index 00000000..e0442ef8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/jest.config.js @@ -0,0 +1,14 @@ +// @ts-check +/** + * @type {import('ts-jest').JestConfigWithTsJest} + */ +const config = { + preset: '@metamask/snaps-jest', + transform: { + '^.+\\.(t|j)sx?$': 'ts-jest', + }, + resetMocks: true, + testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'], +}; + +export default config; diff --git a/merged-packages/tron-wallet-snap/jest.integration.config.js b/merged-packages/tron-wallet-snap/jest.integration.config.js new file mode 100644 index 00000000..4c82e226 --- /dev/null +++ b/merged-packages/tron-wallet-snap/jest.integration.config.js @@ -0,0 +1,11 @@ +// @ts-check +/** + * @type {import('ts-jest').JestConfigWithTsJest} + */ +const config = { + preset: '@metamask/snaps-jest', + testMatch: ['**/integration-test/**/*.test.ts'], +}; + +export default config; + diff --git a/merged-packages/tron-wallet-snap/messages.json b/merged-packages/tron-wallet-snap/messages.json new file mode 100644 index 00000000..cba2b7f2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/messages.json @@ -0,0 +1,146 @@ +{ + "reviewTransactionWarning": { + "message": "Review the transaction before proceeding" + }, + "from": { + "message": "From" + }, + "toAddress": { + "message": "To" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "Cancel" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Amount" + }, + "balance": { + "message": "Balance" + }, + "recipient": { + "message": "Recipient" + }, + "network": { + "message": "Network" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Transaction speed" + }, + "transactionSpeedTooltip": { + "message": "The estimated time of the transaction" + }, + "networkFee": { + "message": "Network fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Send" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Sending" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "Review" + }, + "error": { + "message": "Error" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + } +} \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json new file mode 100644 index 00000000..8059b648 --- /dev/null +++ b/merged-packages/tron-wallet-snap/package.json @@ -0,0 +1,65 @@ +{ + "name": "@metamask/tron-wallet-snap", + "version": "0.1.0", + "description": "A Tron wallet Snap.", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snap-tron-wallet.git" + }, + "license": "(MIT-0 OR Apache-2.0)", + "main": "./dist/bundle.js", + "files": [ + "dist/", + "images/", + "snap.manifest.json", + "locales/" + ], + "scripts": { + "allow-scripts": "yarn workspace root allow-scripts", + "build": "mm-snap build && yarn build:locale && yarn build-preinstalled-snap", + "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js", + "build:clean": "yarn clean && yarn build:locale && yarn build", + "build:locale": "node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w", + "build:locale:watch": "npx nodemon --watch packages/snap/messages.json --exec \"node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w\"", + "clean": "rimraf dist", + "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types", + "lint:deps": "depcheck", + "lint:eslint": "eslint . --cache --ext js,jsx,ts,tsx", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", + "lint:misc": "prettier '**/*.json' '**/*.md' --check", + "lint:types": "tsc --noEmit", + "prepublishOnly": "mm-snap manifest", + "serve": "mm-snap serve", + "start": "concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", + "test": "jest --passWithNoTests", + "test:integration": "./integration-test/run-integration.sh" + }, + "devDependencies": { + "@jest/globals": "^30.0.3", + "@metamask/key-tree": "^10.1.1", + "@metamask/keyring-api": "^18.0.0", + "@metamask/keyring-snap-sdk": "^4.0.0", + "@metamask/slip44": "^4.2.0", + "@metamask/snaps-cli": "^8.1.0", + "@metamask/snaps-jest": "^9.2.0", + "@metamask/snaps-sdk": "^9.2.0", + "@metamask/superstruct": "^3.2.1", + "@metamask/utils": "^11.4.2", + "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.20", + "bignumber.js": "^9.3.1", + "concurrently": "^9.2.0", + "dotenv": "^17.0.0", + "jest": "^30.0.3", + "jest-mock-extended": "^4.0.0", + "jest-transform-stub": "2.0.0", + "lodash": "^4.17.21", + "superstruct": "^2.0.2", + "ts-jest": "^29.4.0", + "uuid": "^11.1.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js new file mode 100644 index 00000000..41f461a9 --- /dev/null +++ b/merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js @@ -0,0 +1,77 @@ +// @ts-check + + +const { readFileSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const packageFile = require('../package.json'); + +console.log('[preinstalled-snap] - attempt to build preinstalled snap'); + +/** + * Read the contents of a file and return as a string. + * @param {string} filePath - Path to file. + * @returns {string} File as utf-8 string. + */ +function readFileContents(filePath) { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + console.error(`Error reading file from disk: ${filePath}`, error); + throw error; + } +} + +// Paths to the files +const bundlePath = require.resolve('../dist/bundle.js'); +const iconPath = require.resolve('../images/icon.svg'); +const manifestPath = require.resolve('../snap.manifest.json'); +const englishLocalePath = require.resolve('../locales/en.json'); + +// File Contents +const bundle = readFileContents(bundlePath); +const icon = readFileContents(iconPath); +const manifest = readFileContents(manifestPath); +const englishLocale = readFileContents(englishLocalePath); + +const snapId = + /** @type {import('@metamask/snaps-controllers').PreinstalledSnap['snapId']} */ ( + `npm:${packageFile.name}` + ); + +/** + * @type {import('@metamask/snaps-controllers').PreinstalledSnap} + */ +const preinstalledSnap = { + snapId, + manifest: JSON.parse(manifest), + files: [ + { + path: 'images/icon.svg', + value: icon, + }, + { + path: 'dist/bundle.js', + value: bundle, + }, + { + path: 'locales/en.json', + value: englishLocale, + }, + ], + removable: false, + hideSnapBranding: true, +}; + +// Write preinstalled-snap file +try { + const outputPath = join(__dirname, '..', 'dist/preinstalled-snap.json'); + writeFileSync(outputPath, JSON.stringify(preinstalledSnap, null, 0)); + + console.log( + `[preinstalled-snap] - successfully created preinstalled snap at ${outputPath}`, + ); +} catch (error) { + console.error('Error writing combined file to disk:', error); + throw error; +} diff --git a/merged-packages/tron-wallet-snap/scripts/populate-en-locale.js b/merged-packages/tron-wallet-snap/scripts/populate-en-locale.js new file mode 100644 index 00000000..e3f1ee6c --- /dev/null +++ b/merged-packages/tron-wallet-snap/scripts/populate-en-locale.js @@ -0,0 +1,26 @@ +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const messages = require('../messages.json'); + +console.log('[populate-en-locale] - attempt to populate en locale'); + +const englishLocale = { + locale: 'en', + messages: Object.entries(messages).reduce((acc, [key, { message }]) => { + acc[key] = { message }; + return acc; + }, {}), +}; + +// Write en locale file +try { + writeFileSync( + join(__dirname, '../locales/en.json'), + JSON.stringify(englishLocale, null, 2), + ); + console.log('[populate-en-locale] - en locale populated'); +} catch (error) { + console.error('Error writing en locale file', error); + throw error; +} diff --git a/merged-packages/tron-wallet-snap/snap.config.ts b/merged-packages/tron-wallet-snap/snap.config.ts new file mode 100644 index 00000000..2b200685 --- /dev/null +++ b/merged-packages/tron-wallet-snap/snap.config.ts @@ -0,0 +1,29 @@ +import type { SnapConfig } from '@metamask/snaps-cli'; +import { config as dotenv } from 'dotenv'; +import { resolve } from 'path'; + +dotenv(); + +const config: SnapConfig = { + input: resolve(__dirname, 'src/index.ts'), + server: { + port: 8080, + }, + environment: { + ENVIRONMENT: process.env.ENVIRONMENT ?? '', + // RPC + RPC_URL_MAINNET: process.env.RPC_URL_MAINNET ?? '', + RPC_URL_SHASTA_TESTNET: process.env.RPC_URL_SHASTA_TESTNET ?? '', + RPC_URL_NILE_TESTNET: process.env.RPC_URL_NILE_TESTNET ?? '', + RPC_URL_LOCALNET: process.env.RPC_URL_LOCALNET ?? '', + // Block explorer + EXPLORER_BASE_URL: process.env.EXPLORER_BASE_URL ?? '', + // APIs + PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL ?? '', + LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL ?? '', + }, + polyfills: true, + experimental: { wasm: true }, +}; + +export default config; diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json new file mode 100644 index 00000000..59ecd9be --- /dev/null +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -0,0 +1,79 @@ +{ + "version": "0.1.0", + "description": "Manage Tron using MetaMask", + "proposedName": "Tron", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/snap-tron-wallet.git" + }, + "source": { + "shasum": "rwr8H8hRWo+jd8LfXglNcStMWVvlLn0iZbReS/pkBtc=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "iconPath": "images/icon.svg", + "packageName": "@metamask/tron-wallet-snap", + "registry": "https://registry.npmjs.org/" + } + }, + "locales": [ + "locales/en.json" + ] + }, + "initialConnections": { + "https://portfolio.metamask.io": {}, + "https://portfolio-builds.metafi-dev.codefi.network": {}, + "https://dev.portfolio.metamask.io": {}, + "https://ramps-dev.portfolio.metamask.io": {} + }, + "initialPermissions": { + "endowment:rpc": { + "dapps": true, + "snaps": false + }, + "endowment:keyring": { + "allowedOrigins": [ + "https://portfolio.metamask.io", + "https://portfolio-builds.metafi-dev.codefi.network", + "https://dev.portfolio.metamask.io", + "https://ramps-dev.portfolio.metamask.io" + ] + }, + "snap_getBip32Entropy": [ + { + "path": [ + "m", + "44'", + "195'" + ], + "curve": "secp256k1" + }, + ], + "endowment:network-access": {}, + "snap_manageAccounts": {}, + "snap_manageState": {}, + "snap_dialog": {}, + "snap_getPreferences": {}, + "endowment:cronjob": { + "jobs": [ + { + "duration": "PT30S", + "request": { + "method": "synchronizeAccounts" + } + } + ] + }, + "endowment:assets": { + "scopes": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] + } + }, + "platformVersion": "9.0.0", + "manifestVersion": "0.1" +} \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/src/caching/ICache.ts b/merged-packages/tron-wallet-snap/src/caching/ICache.ts new file mode 100644 index 00000000..af603856 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/caching/ICache.ts @@ -0,0 +1,87 @@ +/** + * Interface for a generic cache implementation. + * + * @template TValue - The type of values stored in the cache + */ +export type ICache = { + /** + * Retrieves a value from the cache by key. + * @param key - The key to retrieve + * @returns The value if found, undefined if not found + */ + get(key: string): Promise; + + /** + * Stores a value in the cache with an optional TTL. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * @param key - The key to store the value under + * @param value - The value to store + * @param ttlMilliseconds - Optional time-to-live in milliseconds. If not provided, the value will not expire. + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + set(key: string, value: TValue, ttlMilliseconds?: number): Promise; + + /** + * Removes a value from the cache. + * @param key - The key to remove + * @returns true if the key was found and removed, false otherwise + */ + delete(key: string): Promise; + + /** + * Removes all values from the cache. + */ + clear(): Promise; + + /** + * Checks if a key exists in the cache. + * @param key - The key to check + * @returns true if the key exists, false otherwise + */ + has(key: string): Promise; + + /** + * Returns all keys currently in the cache. + * @returns Array of keys + */ + keys(): Promise; + + /** + * Returns the number of items in the cache. + * @returns The number of items + */ + size(): Promise; + + /** + * Retrieves a value from the cache without affecting its TTL or last accessed time. + * @param key - The key to peek at + * @returns The value if found, undefined if not found + */ + peek(key: string): Promise; + + /** + * Retrieves multiple values from the cache in a single operation. + * @param keys - Array of keys to retrieve + * @returns Object mapping keys to their values (or undefined if not found) + */ + mget(keys: string[]): Promise>; + + /** + * Stores multiple values in the cache in a single operation. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * @param entries - Array of entries to store, each with key, value, and optional TTL (if not provided, the value will not expire) + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + mset( + entries: { key: string; value: TValue; ttlMilliseconds?: number }[], + ): Promise; + + /** + * Removes multiple values from the cache. + * @param keys - Array of keys to remove + * @returns An object mapping each key to a boolean indicating whether it was found and removed + */ + mdelete(keys: string[]): Promise>; +}; diff --git a/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts b/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts new file mode 100644 index 00000000..9f4a84c1 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts @@ -0,0 +1,182 @@ +import { assert } from '@metamask/utils'; + +import type { Serializable } from '../serialization/types'; +import type { ILogger } from '../utils/logger'; +import type { ICache } from './ICache'; +import type { CacheEntry } from './types'; + +/** + * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. + * + * WARNINGS: + * - This cache is not persistent and will be lost when the process is restarted. + */ +export class InMemoryCache implements ICache { + #cache: Map = new Map(); + + public readonly logger: ILogger; + + constructor(logger: ILogger) { + this.logger = logger; + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + #isExpired(cacheEntry: CacheEntry): boolean { + return cacheEntry.expiresAt < Date.now(); + } + + async #cleanupExpiredEntries(): Promise { + const expiredKeys: string[] = []; + for (const [key, entry] of this.#cache.entries()) { + if (this.#isExpired(entry)) { + expiredKeys.push(key); + } + } + await this.mdelete(expiredKeys); + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + this.#cache.clear(); + } + + async has(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return false; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return false; + } + + return true; + } + + async keys(): Promise { + await this.#cleanupExpiredEntries(); + return Array.from(this.#cache.keys()); + } + + async size(): Promise { + await this.#cleanupExpiredEntries(); + return this.#cache.size; + } + + async peek(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return undefined; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return undefined; + } + + return cacheEntry.value; + } + + async mget( + keys: string[], + ): Promise> { + await this.#cleanupExpiredEntries(); + + const result: Record = {}; + + for (const key of keys) { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + this.logger.info(`[InMemoryCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + continue; + } + + this.logger.info(`[InMemoryCache] 🎉 Cache hit for key "${key}"`); + result[key] = cacheEntry.value; + } + + return result; + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + }); + } + + async mdelete(keys: string[]): Promise> { + return Object.fromEntries( + keys.map((key) => [key, this.#cache.delete(key)]), + ); + } +} diff --git a/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts b/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts new file mode 100644 index 00000000..4c30ba69 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts @@ -0,0 +1,781 @@ +/* eslint-disable jest/prefer-strict-equal */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { InMemoryState } from '../services/state/InMemoryState'; +import { StateCache } from './StateCache'; + +describe('StateCache', () => { + describe('constructor', () => { + it('uses the default prefix if not specified', () => { + const cache = new StateCache(new InMemoryState({})); + + expect(cache.prefix).toBe('__cache__default'); + }); + + it('uses the specified prefix if provided', () => { + const cache = new StateCache( + new InMemoryState({}), + undefined, + '__cache__my-prefix', + ); + + expect(cache.prefix).toBe('__cache__my-prefix'); + }); + }); + + describe('get', () => { + it('returns undefined if the cache is not initialized', async () => { + const stateWithNoCache = new InMemoryState({ + name: 'John', // State has some data that is not related to the cache + // __cache__default: {} // State has not been initialized with cached data + }); + const cache = new StateCache(stateWithNoCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('returns undefined if the cache is initialized but the key is not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const value = await cache.get('someOtherKey'); + + expect(value).toBeUndefined(); + }); + + it('returns the cached value if the cache is initialized and the key is present and not expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, // Expires in a long time + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + }); + + it('returns undefined if the cache is initialized and the key is present but expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.get('someKey'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('set', () => { + it('initializes the cache if it is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with no expiration if no ttl is provided', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('overwrites the cache entry if it is present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.set('someKey', 'someOtherValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with the provided ttl', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + jest.spyOn(Date, 'now').mockReturnValueOnce(1704067200000); // January 1, 2024 + + await cache.set('someKey', 'someValue', 1000); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067201000, // January 1, 2024 + 1 second + }, + }, + }); + }); + + it('supports a ttl of 0', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + jest + .spyOn(Date, 'now') + .mockReturnValueOnce(1704067200000) // January 1, 2024 + .mockReturnValueOnce(1704067200001); // January 1, 2024 + 1 millisecond + + await cache.set('someKey', 'someValue', 0); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 (+ 0 seconds) + }, + }, + }); + + const value = await cache.get('someKey'); // Should expire immediately + expect(value).toBeUndefined(); + }); + + it('throws an error if the ttl is not a number', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', 'not a number' as unknown as number), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws an error if the ttl is negative', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + + await expect(cache.set('someKey', 'someValue', -1)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws an error if the ttl is too large', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = new StateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + }); + + describe('delete', () => { + it('deletes the cache entry and returns true if the entry was present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.delete('someKey'); + expect(result).toBe(true); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('leaves the cache unchanged and returns false if the entry was not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.delete('someOtherKey'); // Try to + const someKeyValue = await cache.get('someKey'); + const someOtherKeyValue = await cache.get('someOtherKey'); + + expect(result).toBe(false); + expect(someKeyValue).toBe('someValue'); + expect(someOtherKeyValue).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('empties the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + createdAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('has', () => { + it('returns true if the key is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.has('someKey'); + + expect(result).toBe(true); + }); + + it('returns false if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.has('someOtherKey'); + expect(result).toBe(false); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.has('someKey'); + expect(result).toBe(false); + }); + }); + + describe('keys', () => { + it('returns all keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual(['someKey', 'someOtherKey']); + }); + + it('returns an empty array if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual([]); + }); + }); + + describe('size', () => { + it('returns the number of items in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(2); + }); + + it('returns 0 if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(0); + }); + }); + + describe('peek', () => { + it('returns the value of an unexpired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns the value of an expired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns undefined if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.peek('someOtherKey'); + + expect(result).toBeUndefined(); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.peek('someKey'); + expect(result).toBeUndefined(); + }); + }); + + describe('mget', () => { + it('returns the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('returns undefined for keys that are not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: 'someValue', + someOtherKey: undefined, + }); + }); + + it('returns undefined for keys that are expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.mget(['someKey']); + + expect(result).toEqual({ + someKey: undefined, + }); + }); + + it('returns an empty object if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({}); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.mget(['someKey']); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + }); + + describe('mset', () => { + it('sets the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'someOtherKey', value: 'someOtherValue' }, + ]); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('does not store undefined values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'undefinedKey', value: undefined }, + ]); + + const result = await cache.mget(['someKey', 'undefinedKey']); + + expect(result).toEqual({ + someKey: 'someValue', + undefinedKey: undefined, + }); + + // Verify the undefined value was not stored in the cache + const stateValue = await stateWithCache.get(); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('stores null values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: null }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: null, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: 'someValue' }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + }); + }); + + it('throws an error if the ttl is invalid', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + await expect( + cache.mset([ + { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 'not a number' as unknown as number, + }, + ]), + ).rejects.toThrow('TTL must be a number'); + }); + + it('does not affect other keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey0: { + value: 'someValue0', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someKey1: { + value: 'someValue1', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey0', value: 'someValue0Overwritten' }, + { key: 'someKey2', value: 'someValue2' }, + ]); + + const result = await cache.mget(['someKey0', 'someKey1', 'someKey2']); + + expect(result).toStrictEqual({ + someKey0: 'someValue0Overwritten', + someKey1: 'someValue1', + someKey2: 'someValue2', + }); + }); + + it('no-ops if no entries are provided', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + const updateSpy = jest.spyOn(stateWithCache, 'update'); + + await cache.mset([]); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('defers to set if there is only one entry', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + const setSpy = jest.spyOn(cache, 'set'); + + const singleEntry = { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 1000, + }; + await cache.mset([singleEntry]); + + expect(setSpy).toHaveBeenCalledWith( + singleEntry.key, + singleEntry.value, + singleEntry.ttlMilliseconds, + ); + }); + }); + + describe('mdelete', () => { + it('deletes the keys from the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + await cache.mdelete(['someKey', 'someOtherKey']); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: undefined, + someOtherKey: undefined, + }); + }); + + it('returns an object where the values are true if the keys were deleted and false if they were not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = new StateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: true, + someOtherKey: false, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = new StateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: false, + someOtherKey: false, + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/caching/StateCache.ts b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts new file mode 100644 index 00000000..988d5893 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts @@ -0,0 +1,257 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { assert } from '@metamask/utils'; + +import type { Serializable } from '../serialization/types'; +import type { IStateManager } from '../services/state/IStateManager'; +import type { ILogger } from '../utils/logger'; +import type { ICache } from './ICache'; +import type { CacheEntry } from './types'; + +/** + * The whole cache store. + */ +export type CacheStore = Record | undefined; + +/** + * A prefix for the cache "location" in the state. Enforced to start with `__cache__` to avoid collisions with other state values. + */ +export type CachePrefix = `__cache__${string}`; + +/** + * Describes the shape of the whole state inside which the cache is stored. + */ +export type StateValue = { + [x: string]: Serializable; +} & { + [K in CachePrefix]?: CacheStore; +}; + +/** + * A cache that wraps any implementation of the `IStateManager` interface to store the cache. + * + * It is intended to be used with the snap's `State` class, but can be used with any other implementation of the `IStateManager` interface. For instance it can be used with the `InMemoryState` class for testing purposes. + * + * By default, it stores its data in the `__cache__default` property of the state, but you can specify any other prefix you want, provided it starts with `__cache__` to avoid collisions with other state values. + * This is useful if you want to have multiple independent caches in the same state. + * + * ``` + * { + * ..., // other state values + * __cache__default: { + * key1: value1, + * key2: value2, + * }, + * __cache__my-prefix: { + * key3: value3, + * key4: value4, + * }, + * } + * ``` + * + * @example + * ```ts + * const state = new State({}); // Here we use the real snap's state + * const cache = new StateCache(state, '__cache__my-prefix'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // no __cache__my-prefix yet + * // } + * + * await cache.set('key1', 'value1'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // __cache__my-prefix: { + * // key1: value1, + * // }, + * // } + * ``` + */ +export class StateCache implements ICache { + #state: IStateManager; + + public readonly prefix: CachePrefix; + + public readonly logger: ILogger; + + constructor( + state: IStateManager, + logger: ILogger = console, + prefix: CachePrefix = '__cache__default', + ) { + this.#state = state; + this.logger = logger; + this.prefix = prefix; + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + await this.#state.setKey(`${this.prefix}.${key}`, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + await this.#state.setKey(this.prefix, {}); + } + + async has(key: string): Promise { + const result = await this.get(key); + return result !== undefined; + } + + async keys(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}); + } + + async size(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}).length; + } + + async peek(key: string): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + const cacheEntry = cacheStore?.[key]; + + return cacheEntry?.value; + } + + async mget( + keys: string[], + ): Promise> { + const cacheStore = await this.#state.getKey(this.prefix); + + const keysAndValues = Object.entries(cacheStore ?? {}).filter(([key]) => + keys.includes(key), + ); + + const expiredKeys = keysAndValues.filter( + ([_, cacheEntry]) => cacheEntry && cacheEntry.expiresAt < Date.now(), + ); + + await this.mdelete(expiredKeys.map(([key]) => key)); + + return keysAndValues.reduce>( + (acc, [key, cacheEntry]) => { + if (cacheEntry === undefined) { + this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + return acc; + } + + if (cacheEntry.expiresAt < Date.now()) { + this.logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); + acc[key] = undefined; + } else { + this.logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); + acc[key] = cacheEntry.value; + } + + return acc; + }, + {}, + ); + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + // Using `state.update` is preferred for bulk `set`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + cacheStore[key] = { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }; + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + } + + async mdelete(keys: string[]): Promise> { + const result: Record = {}; + + // Using `state.update` is preferred for bulk `delete`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + keys.forEach((key) => { + if (cacheStore[key] === undefined) { + result[key] = false; + } else { + delete cacheStore[key]; + result[key] = true; + } + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + + return result; + } +} diff --git a/merged-packages/tron-wallet-snap/src/caching/types.ts b/merged-packages/tron-wallet-snap/src/caching/types.ts new file mode 100644 index 00000000..a06842d0 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/caching/types.ts @@ -0,0 +1,11 @@ +import type { Serializable } from '../serialization/types'; + +export type TimestampMilliseconds = number; + +/** + * A single cache entry. + */ +export type CacheEntry = { + value: Serializable; + expiresAt: TimestampMilliseconds; +}; diff --git a/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts b/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts new file mode 100644 index 00000000..183940f2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts @@ -0,0 +1,245 @@ +import type { Serializable } from '../serialization/types'; +import type { ICache } from './ICache'; +import { useCache, type CacheOptions } from './useCache'; + +describe('useCache', () => { + // Spy to check if the original function was executed or not + let actualExecutionSpy: jest.Mock; + + // Mock cache + let cache: ICache; + + // Common cache options + let cacheOptions: CacheOptions; + + // Original test functions + let testFunction: () => Promise; + let testFunctionWithArgs: (arg1: string, arg2: number) => Promise; + let testFunctionWithComplexArgs: (obj: { + name: string; + age: number; + }) => Promise; + + // Cached versions + let cachedTestFunction: () => Promise; + let cachedTestFunctionWithArgs: ( + arg1: string, + arg2: number, + ) => Promise; + let cachedTestFunctionWithComplexArgs: (obj: { + name: string; + age: number; + }) => Promise; + + beforeEach(() => { + // Reset mocks for each test + actualExecutionSpy = jest.fn().mockResolvedValue('test'); + + // Create a mock cache + cache = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + } as unknown as ICache; + + // Define common cache options + cacheOptions = { + ttlMilliseconds: 1000, + functionName: 'testFunction', + }; + + // Define original functions + testFunction = async () => actualExecutionSpy(); + testFunctionWithArgs = async (arg1: string, arg2: number) => + actualExecutionSpy(arg1, arg2); + testFunctionWithComplexArgs = async (obj: { name: string; age: number }) => + actualExecutionSpy(obj); + + // Create cached versions + cachedTestFunction = useCache(testFunction, cache, { + ...cacheOptions, + functionName: 'testFunction', + }); + + cachedTestFunctionWithArgs = useCache(testFunctionWithArgs, cache, { + ...cacheOptions, + functionName: 'testFunctionWithArgs', + }); + + cachedTestFunctionWithComplexArgs = useCache( + testFunctionWithComplexArgs, + cache, + { + ...cacheOptions, + functionName: 'testFunctionWithComplexArgs', + }, + ); + }); + + describe('when the data is not cached', () => { + it('should cache the result of a function', async () => { + // No cached data + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(cache.get).toHaveBeenCalledTimes(1); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + }); + + describe('when the data is cached', () => { + it('should return the cached result', async () => { + // Init the cache with some data + jest.spyOn(cache, 'get').mockResolvedValue('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(cache.get).toHaveBeenCalledTimes(1); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should propagate errors from the original function', async () => { + const error = new Error('Test error'); + actualExecutionSpy.mockRejectedValueOnce(error); + + await expect(cachedTestFunction()).rejects.toThrow('Test error'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('should handle cache get errors gracefully', async () => { + jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + + it('should handle cache set errors gracefully', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('different argument types', () => { + it('should handle primitive arguments correctly', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); + actualExecutionSpy.mockResolvedValueOnce('test with args'); + + const result = await cachedTestFunctionWithArgs('hello', 42); + + expect(result).toBe('test with args'); + expect(cache.get).toHaveBeenCalledWith('testFunctionWithArgs:"hello":42'); + expect(actualExecutionSpy).toHaveBeenCalledWith('hello', 42); + }); + + it('should handle complex object arguments correctly', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); + const testObj = { name: 'John', age: 30 }; + actualExecutionSpy.mockResolvedValueOnce('test with complex args'); + + const result = await cachedTestFunctionWithComplexArgs(testObj); + + expect(result).toBe('test with complex args'); + expect(cache.get).toHaveBeenCalledWith( + 'testFunctionWithComplexArgs:{"name":"John","age":30}', + ); + expect(actualExecutionSpy).toHaveBeenCalledWith(testObj); + }); + }); + + describe('custom generateCacheKey', () => { + it('should use a custom key generator if provided', async () => { + const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); + + const customCachedFunction = useCache(testFunction, cache, { + ...cacheOptions, + generateCacheKey: customKeyGenerator, + }); + + await customCachedFunction(); + + expect(customKeyGenerator).toHaveBeenCalledTimes(1); + expect(cache.get).toHaveBeenCalledWith('custom-key'); + }); + }); + + describe('anonymous functions', () => { + it('should handle anonymous functions with a default name', async () => { + // Anonymous function with no name + const anonymousFunction = async () => actualExecutionSpy(); + Object.defineProperty(anonymousFunction, 'name', { value: null }); + + const cachedAnonymousFunction = useCache(anonymousFunction, cache, { + ttlMilliseconds: 1000, + }); + + await cachedAnonymousFunction(); + + expect(cache.get).toHaveBeenCalledWith('anonymousFunction:'); + }); + }); + + describe('function name override', () => { + it('should use the provided function name if given', async () => { + const cachedWithCustomName = useCache(testFunction, cache, { + ttlMilliseconds: 1000, + functionName: 'customFunctionName', + }); + + await cachedWithCustomName(); + + expect(cache.get).toHaveBeenCalledWith('customFunctionName:'); + }); + }); + + describe('falsy but valid cache values', () => { + it('should handle falsy but valid cache values (false, 0, empty string)', async () => { + // Test with false + jest.spyOn(cache, 'get').mockResolvedValue(false); + let result = await cachedTestFunction(); + expect(result).toBe(false); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + + // Test with 0 + jest.spyOn(cache, 'get').mockResolvedValue(0); + result = await cachedTestFunction(); + expect(result).toBe(0); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + + // Test with empty string + jest.spyOn(cache, 'get').mockResolvedValue(''); + result = await cachedTestFunction(); + expect(result).toBe(''); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + }); + + it('should execute the function when cache returns undefined', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/caching/useCache.ts b/merged-packages/tron-wallet-snap/src/caching/useCache.ts new file mode 100644 index 00000000..9e4a7ac0 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/caching/useCache.ts @@ -0,0 +1,86 @@ +/* eslint-disable no-void */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Serializable } from '../serialization/types'; +import type { ICache } from './ICache'; + +/** + * Options for configuring the caching behavior of a function. + */ +export type CacheOptions = { + /** + * The time to live for the cache in milliseconds. + */ + ttlMilliseconds: number; + /** + * Set this if you want to use a custom function name for the cache key. + */ + functionName?: string; + /** + * Optional function to generate the cache key for the function call. + * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. + */ + generateCacheKey?: (functionName: string, args: any[]) => string; +}; + +/** + * Default function to generate the cache key for a function call. + * + * @param functionName - The name of the function. + * @param args - The arguments of the function call. + * @returns The cache key. + */ +const defaultGenerateCacheKey = (functionName: string, args: any[]) => + `${functionName}:${args.map((arg) => JSON.stringify(arg)).join(':')}`; + +/** + * Wraps a function with caching behavior. + * + * @template TArgs - Tuple type representing the arguments of the function. + * @template TResult - The return type of the function, must be Serializable. + * @param fn - The asynchronous function to wrap. Must return a Promise. + * @param cache - The cache instance to use. + * @param options - The caching options. + * @param options.ttlMilliseconds - The time to live for the cache in milliseconds. + * @param options.functionName - The name of the function. + * @param options.generateCacheKey - Optional function to generate the cache key. + * @returns A new asynchronous function with caching behavior. + */ +export const useCache = ( + fn: (...args: TArgs) => Promise, + cache: ICache, + { ttlMilliseconds, functionName, generateCacheKey }: CacheOptions, +): ((...args: TArgs) => Promise) => { + // Use provided key generator or default, adapting the default to use the function's name + const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; + + // Get the function name for the default key generator, handle anonymous functions + const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; + + return async (...args: TArgs): Promise => { + const cacheKey = _generateCacheKey(_functionName, args); + + // Check if the data is cached + try { + const cached = await cache.get(cacheKey); + // Check explicitly for undefined, as null or other falsy values might be valid cache results + if (cached !== undefined) { + // Type assertion because cache stores Serializable, but we expect TResult + return cached as TResult; + } + } catch (error) { + // Log cache get errors but proceed to execute the function + console.error(`Cache get error for key "${cacheKey}":`, error); + } + + // Execute the original function + const result = await fn(...args); + + // Cache the result, handle potential errors silently + // We don't await this, allowing it to happen in the background + void cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { + console.error(`Cache set error for key "${cacheKey}":`, error); + }); + + return result; + }; +}; diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts new file mode 100644 index 00000000..10527e78 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts @@ -0,0 +1,384 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { CaipAssetType } from '@metamask/keyring-api'; +import { cloneDeep } from 'lodash'; + +import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; +import { KnownCaip19Id } from '../../constants/solana'; +import type { Serializable } from '../../serialization/types'; +import type { ConfigProvider } from '../../services/config'; +import { mockLogger } from '../../services/mocks/logger'; +import { MOCK_EXCHANGE_RATES } from '../../test/mocks/price-api/exchange-rates'; +import { MOCK_HISTORICAL_PRICES } from './mocks/historical-prices'; +import { MOCK_SPOT_PRICES } from './mocks/spot-prices'; +import { PriceApiClient } from './PriceApiClient'; +import type { SpotPrices, VsCurrencyParam } from './types'; + +describe('PriceApiClient', () => { + let mockFetch: jest.Mock; + let mockCache: ICache; + let client: PriceApiClient; + + beforeEach(() => { + mockFetch = jest.fn(); + + const mockConfigProvider: ConfigProvider = { + get: jest.fn().mockReturnValue({ + priceApi: { + baseUrl: 'https://some-mock-url.com', + chunkSize: 50, + cacheTtlsMilliseconds: { + fiatExchangeRates: 0, + spotPrices: 0, + historicalPrices: 0, + }, + }, + }), + } as unknown as ConfigProvider; + + mockCache = new InMemoryCache(mockLogger); + + client = new PriceApiClient( + mockConfigProvider, + mockCache, + mockFetch, + mockLogger, + ); + }); + + describe('getFiatExchangeRates', () => { + it('fetches fiat exchange rates successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_EXCHANGE_RATES), + }); + + const result = await client.getFiatExchangeRates(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v1/exchange-rates/fiat', + ); + expect(result).toStrictEqual(MOCK_EXCHANGE_RATES); + }); + }); + + describe('getMultipleSpotPrices', () => { + const mockResponse: SpotPrices = { + [KnownCaip19Id.SolMainnet]: MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, + [KnownCaip19Id.UsdcMainnet]: MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + }; + + describe('when the data is not cached', () => { + it('fetches multiple spot prices successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + const result = await client.getMultipleSpotPrices([ + KnownCaip19Id.SolMainnet, + KnownCaip19Id.UsdcMainnet, + ]); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=usd&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Fslip44%3A501%2Csolana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + ); + expect(result).toStrictEqual(mockResponse); + }); + + it('logs and throws an error if fetch fails', async () => { + const mockError = new Error('Fetch failed'); + mockFetch.mockRejectedValueOnce(mockError); + + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + KnownCaip19Id.UsdcLocalnet, + ]), + ).rejects.toThrow('Fetch failed'); + expect(mockLogger.error).toHaveBeenCalledWith( + mockError, + 'Error fetching spot prices', + ); + }); + + it('throws an error if response is not ok', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + KnownCaip19Id.UsdcLocalnet, + ]), + ).rejects.toThrow('HTTP error! status: 404'); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'Error fetching spot prices', + ); + }); + + it('fetches spot price with custom vsCurrency', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_SPOT_PRICES), + }); + + await client.getMultipleSpotPrices( + [KnownCaip19Id.SolMainnet, KnownCaip19Id.UsdcMainnet], + 'eur', + ); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=eur&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Fslip44%3A501%2Csolana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + ); + }); + + it('handles malformed JSON response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValueOnce(new Error('Invalid JSON')), + }); + + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + KnownCaip19Id.UsdcLocalnet, + ]), + ).rejects.toThrow('Invalid JSON'); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'Error fetching spot prices', + ); + }); + + it('handles network timeout', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network timeout')); + + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + KnownCaip19Id.UsdcLocalnet, + ]), + ).rejects.toThrow('Network timeout'); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'Error fetching spot prices', + ); + }); + + it('throws when malformed response from the Price API', async () => { + const mockMalformedResponse = cloneDeep(mockResponse); + mockMalformedResponse[KnownCaip19Id.SolMainnet]!.price = -999; // Price must be a positive number + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockMalformedResponse), + }); + + await expect( + client.getMultipleSpotPrices([KnownCaip19Id.SolMainnet]), + ).rejects.toThrow( + 'At path: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501.price -- Expected a number greater than or equal to 0 but received `-999`', + ); + }); + }); + + describe('when the data is fully cached', () => { + it('returns the cached data', async () => { + const cachedData = { + 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501:usd': + MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, + 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v:usd': + MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + }; + jest.spyOn(mockCache, 'mget').mockResolvedValueOnce(cachedData); + jest.spyOn(mockCache, 'mset').mockResolvedValueOnce(undefined); + + const result = await client.getMultipleSpotPrices([ + KnownCaip19Id.SolMainnet, + KnownCaip19Id.UsdcMainnet, + ]); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockCache.mset).not.toHaveBeenCalled(); + }); + }); + + describe('when the data is partially cached', () => { + it('returns the cached data', async () => { + // Only the first token is cached, we will need to fetch the second one + const cachedData = { + 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501:usd': + MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, + }; + jest.spyOn(mockCache, 'mget').mockResolvedValueOnce(cachedData); + jest.spyOn(mockCache, 'mset').mockResolvedValueOnce(undefined); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + [KnownCaip19Id.UsdcMainnet]: + MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + }), + }); + + const result = await client.getMultipleSpotPrices([ + KnownCaip19Id.SolMainnet, + KnownCaip19Id.UsdcMainnet, + ]); + + expect(result).toStrictEqual(mockResponse); + + // We should have fetched the second token only + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=usd&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + ); + + // The second token should be added to the cache + expect(mockCache.mset).toHaveBeenCalledWith([ + { + key: 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v:usd', + value: MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + ttlMilliseconds: 0, + }, + ]); + }); + }); + }); + + describe('security', () => { + it('rejects invalid base URLs in constructor', () => { + const invalidConfigProvider = { + get: jest.fn().mockReturnValue({ + priceApi: { + baseUrl: 'invalid-url', + chunkSize: 50, + cacheTtlsMilliseconds: { + fiatExchangeRates: 0, + spotPrices: 0, + historicalPrices: 0, + }, + }, + }), + } as unknown as ConfigProvider; + + expect( + () => + new PriceApiClient( + invalidConfigProvider, + mockCache, + mockFetch, + mockLogger, + ), + ).toThrow('Invalid URL format'); + }); + + it('rejects tokenCaipAssetTypes that are invalid or that include malicious inputs', async () => { + await expect( + client.getMultipleSpotPrices([ + KnownCaip19Id.SolLocalnet, + 'INVALID' as CaipAssetType, + ]), + ).rejects.toThrow( + 'At path: 1 -- Expected a value of type `CaipAssetType`, but received: `"INVALID"`', + ); + }); + + it('rejects vsCurrency parameters that are invalid or that include malicious inputs', async () => { + await expect( + client.getMultipleSpotPrices( + [KnownCaip19Id.SolLocalnet], + 'INVALID' as VsCurrencyParam, + ), + ).rejects.toThrow(/Expected/u); + }); + + it('handles URLs with multiple query parameters safely', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({}), + }); + + await client.getMultipleSpotPrices([KnownCaip19Id.SolLocalnet]); + + // Verify URL is properly constructed with encoded parameters + expect(mockFetch).toHaveBeenCalledWith( + expect.stringMatching( + /^https:\/\/some-mock-url\.com\/v3\/spot-prices\?([^&=]+=[^&]*&)*[^&=]+=.+$/u, + ), + ); + }); + + it('rejects non-printable characters in input', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({}), + }); + + await expect( + client.getMultipleSpotPrices( + [KnownCaip19Id.SolLocalnet], + 'usd\x00\x1F' as VsCurrencyParam, + ), + ).rejects.toThrow(/Expected/u); + }); + }); + + describe('getHistoricalPrices', () => { + describe('when the data is not cached', () => { + it('fetches historical prices successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_HISTORICAL_PRICES), + }); + + const cacheSetSpy = jest.spyOn(mockCache, 'set'); + + const result = await client.getHistoricalPrices({ + assetType: KnownCaip19Id.SolMainnet, + timePeriod: '5d', + from: 123, + to: 456, + vsCurrency: 'usd', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://some-mock-url.com/v3/historical-prices/solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501?timePeriod=5d&from=123&to=456&vsCurrency=usd', + ); + expect(cacheSetSpy).toHaveBeenCalledWith( + 'PriceApiClient:getHistoricalPrices:{"assetType":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501","timePeriod":"5d","from":123,"to":456,"vsCurrency":"usd"}', + MOCK_HISTORICAL_PRICES, + 0, + ); + expect(result).toStrictEqual(MOCK_HISTORICAL_PRICES); + }); + }); + + describe('when the data is cached', () => { + it('returns the cached data', async () => { + jest + .spyOn(mockCache, 'get') + .mockResolvedValueOnce(MOCK_HISTORICAL_PRICES); + + const cacheGetSpy = jest.spyOn(mockCache, 'get'); + const cacheSetSpy = jest.spyOn(mockCache, 'set'); + + const result = await client.getHistoricalPrices({ + assetType: KnownCaip19Id.SolMainnet, + timePeriod: '5d', + from: 123, + to: 456, + vsCurrency: 'usd', + }); + + expect(cacheGetSpy).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toStrictEqual(MOCK_HISTORICAL_PRICES); + expect(cacheSetSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts new file mode 100644 index 00000000..1a5d6222 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts @@ -0,0 +1,320 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-restricted-globals */ +import type { CaipAssetType } from '@metamask/keyring-api'; +import { array, assert } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; +import { mapKeys } from 'lodash'; + +import type { ICache } from '../../caching/ICache'; +import { useCache } from '../../caching/useCache'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import type { Serializable } from '../../utils/serialization/types'; +import { UrlStruct } from '../../validation/structs'; +import type { + ExchangeRate, + FiatTicker, + GetHistoricalPricesParams, + GetHistoricalPricesResponse, + SpotPrices, + VsCurrencyParam, +} from './types'; +import { + GetHistoricalPricesParamsStruct, + GetHistoricalPricesResponseStruct, + SpotPricesStruct, + VsCurrencyParamStruct, +} from './types'; + +export class PriceApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + readonly #chunkSize: number; + + readonly #cache: ICache; + + readonly cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + + constructor( + configProvider: ConfigProvider, + _cache: ICache, + _fetch: typeof globalThis.fetch = globalThis.fetch, + _logger: ILogger = logger, + ) { + const { baseUrl, chunkSize, cacheTtlsMilliseconds } = + configProvider.get().priceApi; + + assert(baseUrl, UrlStruct); + + this.#fetch = _fetch; + this.#logger = _logger; + this.#baseUrl = baseUrl; + this.#chunkSize = chunkSize; + this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; + + this.#cache = _cache; + } + + async getFiatExchangeRates(): Promise> { + try { + const response = await this.#fetch( + `${this.#baseUrl}/v1/exchange-rates/fiat`, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error) { + this.#logger.error(error, 'Error fetching fiat exchange rates'); + throw error; + } + } + + /** + * Business logic for `getMultipleSpotPrices`. + * + * @param tokenCaipAssetTypes - The CAIP-19 IDs of the tokens to get the spot prices for. + * @param vsCurrency - The currency to convert the prices to. + * @returns The spot prices for the tokens. + */ + async #getMultipleSpotPrices_INTERNAL( + tokenCaipAssetTypes: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + ): Promise { + try { + assert(tokenCaipAssetTypes, array(CaipAssetTypeStruct)); + assert(vsCurrency, VsCurrencyParamStruct); + + if (tokenCaipAssetTypes.length === 0) { + return {}; + } + + const uniqueTokenCaipAssetTypes = [...new Set(tokenCaipAssetTypes)]; + + // Split uniqueTokenCaipAssetTypes into chunks + const chunks: CaipAssetType[][] = []; + for ( + let i = 0; + i < uniqueTokenCaipAssetTypes.length; + i += this.#chunkSize + ) { + chunks.push(uniqueTokenCaipAssetTypes.slice(i, i + this.#chunkSize)); + } + + // Make parallel requests for each chunk + const responses = await Promise.all( + chunks.map(async (chunk) => { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/spot-prices', + queryParams: { + vsCurrency, + assetIds: chunk.join(','), + includeMarketData: 'true', + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const spotPrices = await response.json(); + assert(spotPrices, SpotPricesStruct); + + return spotPrices; + }), + ); + + // Combine all responses + const spotPrices = responses.reduce( + (prices, price) => ({ ...prices, ...price }), + {}, + ); + + // Store in the cache + await this.#cache.mset( + tokenCaipAssetTypes.map((tokenCaipAssetType) => ({ + key: `PriceApiClient:getMultipleSpotPrices:${tokenCaipAssetType}:${vsCurrency}`, + value: spotPrices[tokenCaipAssetType], + ttlMilliseconds: this.cacheTtlsMilliseconds.spotPrices, + })), + ); + + return spotPrices; + } catch (error) { + this.#logger.error(error, 'Error fetching spot prices'); + throw error; + } + } + + /** + * Internal caching logic for `getMultipleSpotPrices`: + * - Uses mget/mset for batch operations. + * - Handles proper cache key management. + * - Handles partial cache hits (fetches only non-cached conversions). + * + * @param tokenCaip19Types - The CAIP-19 IDs of the tokens to get the spot prices for. + * @param vsCurrency - The currency to convert the prices to. + * @returns The spot prices for the tokens. + */ + async #getMultipleSpotPrices_CACHE( + tokenCaip19Types: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + ): Promise { + const uniqueTokenCaip19Types = [...new Set(tokenCaip19Types)]; + + const cacheKeyPrefix = 'PriceApiClient:getMultipleSpotPrices'; + + // Shorthand method to generate the cache key + const toCacheKey = (tokenCaipAssetType: CaipAssetType) => + `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`; + + // Parses back the cache key + const parseCacheKey = (key: string) => { + const regex = new RegExp(`^${cacheKeyPrefix}:(.+):(.+)$`, 'u'); + const match = key.match(regex); + + if (!match) { + throw new Error('Invalid cache key'); + } + + return match; + }; + + // Get the cached spot prices + const cachedSpotPricesRecord = await this.#cache.mget( + uniqueTokenCaip19Types.map(toCacheKey), + ); + + // Keys when read from the cache are the cache keys ("PriceApiClient:getMultipleSpotPrices:..."), not the token CAIP-19 IDs, so here we transform them back to the token CAIP-19 types. + const cachedSpotPricesRecordWithParsedKeys = mapKeys( + cachedSpotPricesRecord, + (_, key) => parseCacheKey(key)[1], + ); + + // We still need to fetch the spot prices for the tokens that are not cached + const nonCachedTokenCaip19Types = uniqueTokenCaip19Types.filter( + (tokenCaip19Type) => + cachedSpotPricesRecordWithParsedKeys[tokenCaip19Type] === undefined, + ); + + if (nonCachedTokenCaip19Types.length === 0) { + return cachedSpotPricesRecordWithParsedKeys as SpotPrices; + } + + // Fetch the spot prices for the tokens that are not cached + const nonCachedSpotPrices = await this.#getMultipleSpotPrices_INTERNAL( + nonCachedTokenCaip19Types, + vsCurrency, + ); + + // Cache the data + await this.#cache.mset( + Object.entries(nonCachedSpotPrices).map( + ([tokenCaipAssetType, spotPrice]) => ({ + key: toCacheKey(tokenCaipAssetType as CaipAssetType), + value: spotPrice, + ttlMilliseconds: this.cacheTtlsMilliseconds.spotPrices, + }), + ), + ); + + return { + ...cachedSpotPricesRecordWithParsedKeys, + ...nonCachedSpotPrices, + }; + } + + /** + * Get multiple spot prices for a list of tokens. + * It caches the results for 1 hour. + * + * @param tokenCaip19Types - The CAIP-19 IDs of the tokens to get the spot prices for. + * @param vsCurrency - The currency to convert the prices to. + * @returns The spot prices for the tokens. + */ + async getMultipleSpotPrices( + tokenCaip19Types: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + ): Promise { + return this.#getMultipleSpotPrices_CACHE(tokenCaip19Types, vsCurrency); + } + + /** + * Business logic for `getHistoricalPrices`. + * + * @param params - The parameters for the request. + * @param params.assetType - The asset type of the token. + * @param params.timePeriod - The time period for the historical prices. + * @param params.from - The start date for the historical prices. + * @param params.to - The end date for the historical prices. + * @param params.vsCurrency - The currency to convert the prices to. + * @returns The historical prices for the token. + */ + async #getHistoricalPrices_INTERNAL( + params: GetHistoricalPricesParams, + ): Promise { + assert(params, GetHistoricalPricesParamsStruct); + + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/historical-prices/{assetType}', + pathParams: { + assetType: params.assetType, + }, + queryParams: { + ...(params.timePeriod && { timePeriod: params.timePeriod }), + ...(params.from && { from: params.from.toString() }), + ...(params.to && { to: params.to.toString() }), + ...(params.vsCurrency && { vsCurrency: params.vsCurrency }), + }, + }); + + const response = await this.#fetch(url); + const historicalPrices = await response.json(); + assert(historicalPrices, GetHistoricalPricesResponseStruct); + + return historicalPrices; + } + + /** + * Get historical prices for a token by calling the Price API. + * It caches the results for 1 hour. + * + * @see https://price.uat-api.cx.metamask.io/docs#/Historical%20Prices/PriceController_getHistoricalPricesByCaipAssetId + * @param params - The parameters for the request. + * @param params.assetType - The asset type of the token. + * @param params.timePeriod - The time period for the historical prices. + * @param params.from - The start date for the historical prices. + * @param params.to - The end date for the historical prices. + * @param params.vsCurrency - The currency to convert the prices to. + * @returns The historical prices for the token. + */ + async getHistoricalPrices( + params: GetHistoricalPricesParams, + ): Promise { + return useCache( + this.#getHistoricalPrices_INTERNAL.bind(this), + this.#cache, + { + functionName: 'PriceApiClient:getHistoricalPrices', + ttlMilliseconds: this.cacheTtlsMilliseconds.historicalPrices, + }, + )(params); + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/historical-prices.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/historical-prices.ts new file mode 100644 index 00000000..c49401ef --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/historical-prices.ts @@ -0,0 +1,17 @@ +export const MOCK_HISTORICAL_PRICES = { + prices: [ + [1740927906629, 0.4118878563926736], + [1740931479807, 0.42205009065536164], + [1740935079843, 0.45470438113431433], + ], + marketCaps: [ + [1740927906629, 1817840725.6040797], + [1740931479807, 1868369182.2913468], + [1740935079843, 2012074624.0219033], + ], + totalVolumes: [ + [1740927906629, 120486002.56343293], + [1740931479807, 147850728.76918542], + [1740935079843, 220405205.04882324], + ], +}; diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/spot-prices.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/spot-prices.ts new file mode 100644 index 00000000..57286400 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/spot-prices.ts @@ -0,0 +1,163 @@ +import type { SpotPrices } from '../types'; + +export const MOCK_SPOT_PRICES: SpotPrices = { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + id: 'bitcoin', + price: 77556.84849999227, + marketCap: 1540421085883.0198, + allTimeHigh: 100847.44951017378, + allTimeLow: 62.86163248290115, + totalVolume: 23748436299.895576, + high1d: 78330.91520288598, + low1d: 75747.29376460482, + circulatingSupply: 19844921, + dilutedMarketCap: 1540421085883.0198, + marketCapPercentChange1d: 1.53313, + priceChange1d: 1132.76, + pricePercentChange1h: -0.4456714429821922, + pricePercentChange1d: 1.3725526422881404, + pricePercentChange7d: -4.2914380354332256, + pricePercentChange14d: 1.3530761284206316, + pricePercentChange30d: -2.6647248645353425, + pricePercentChange200d: 44.69565022141291, + pricePercentChange1y: 20.367003699380124, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'eip155:1/slip44:60': { + id: 'ethereum', + price: 1724.8431002851428, + marketCap: 208326525244.77222, + allTimeHigh: 4522.273813243435, + allTimeLow: 0.4013827867691204, + totalVolume: 14672129201.423573, + high1d: 1750.5958823287203, + low1d: 1670.927268620115, + circulatingSupply: 120659504.7581715, + dilutedMarketCap: 208326525244.77222, + marketCapPercentChange1d: 2.19045, + priceChange1d: 36.42, + pricePercentChange1h: -0.16193070976498064, + pricePercentChange1d: 1.9964598342126199, + pricePercentChange7d: -10.123102834312476, + pricePercentChange14d: -1.7452971064771636, + pricePercentChange30d: -16.78602306244949, + pricePercentChange200d: -21.026646670919543, + pricePercentChange1y: -47.45246230239663, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + id: 'solana', + price: 117.40784182214172, + marketCap: 60217502031.67665, + allTimeHigh: 271.90599356377726, + allTimeLow: 0.46425554356391946, + totalVolume: 3389485617.517553, + high1d: 120.28162239575909, + low1d: 114.6267638476733, + circulatingSupply: 512506275.4700137, + dilutedMarketCap: 70208307228.42435, + marketCapPercentChange1d: 1.82897, + priceChange1d: 2.03, + pricePercentChange1h: -0.7015657267954617, + pricePercentChange1d: 1.6270441732346845, + pricePercentChange7d: -10.985589910714582, + pricePercentChange14d: 2.557473792001135, + pricePercentChange30d: -11.519171371325216, + pricePercentChange200d: -4.453777067234332, + pricePercentChange1y: -35.331458644625535, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + id: 'usd-coin', + price: 0.9270204293335238, + marketCap: 55688170578.59265, + allTimeHigh: 1.084620410042683, + allTimeLow: 0.8136015803527613, + totalVolume: 8880279668.334307, + high1d: 0.9270204293335238, + low1d: 0.9267951620175918, + circulatingSupply: 60073257677.05562, + dilutedMarketCap: 55717659417.21691, + marketCapPercentChange1d: 0.02765, + priceChange1d: 0.00012002, + pricePercentChange1h: 0.005951260480466856, + pricePercentChange1d: 0.012003855299833856, + pricePercentChange7d: 0.010535714950044883, + pricePercentChange14d: 0.013896106834960937, + pricePercentChange30d: 0.009428708838368462, + pricePercentChange200d: -0.03983945503023834, + pricePercentChange1y: 0.0011388468382004923, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501': null, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + id: 'usd-coin', + price: 0.9270204293335238, + marketCap: 55688170578.59265, + allTimeHigh: 1.084620410042683, + allTimeLow: 0.8136015803527613, + totalVolume: 8880279668.334307, + high1d: 0.9270204293335238, + low1d: 0.9267951620175918, + circulatingSupply: 60073257677.05562, + dilutedMarketCap: 55717659417.21691, + marketCapPercentChange1d: 0.02765, + priceChange1d: 0.00012002, + pricePercentChange1h: 0.005951260480466856, + pricePercentChange1d: 0.012003855299833856, + pricePercentChange7d: 0.010535714950044883, + pricePercentChange14d: 0.013896106834960937, + pricePercentChange30d: 0.009428708838368462, + pricePercentChange200d: -0.03983945503023834, + pricePercentChange1y: 0.0011388468382004923, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr': + { + id: 'euro-coin', + price: 1.0002610448171412, + marketCap: 142095635.08509836, + allTimeHigh: 1.2514850885107882, + allTimeLow: 0.04899146959823566, + totalVolume: 25199808.258576106, + high1d: 1.0048961747745884, + low1d: 0.9993340188256516, + circulatingSupply: 142084788.4864096, + dilutedMarketCap: 142095635.08509836, + marketCapPercentChange1d: 2.2575, + priceChange1d: -0.002559725137667668, + pricePercentChange1h: 0.03324277835545184, + pricePercentChange1d: -0.23674527641785267, + pricePercentChange7d: -0.2372037121786562, + pricePercentChange14d: -1.230607529415818, + pricePercentChange30d: 4.034620460890957, + pricePercentChange200d: -2.4622235894139086, + pricePercentChange1y: 0.2685816973195049, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, +} as const; diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/structs.test.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/structs.test.ts new file mode 100644 index 00000000..1d1ca018 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/structs.test.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { assert } from '@metamask/superstruct'; +import { cloneDeep } from 'lodash'; + +import { MOCK_SPOT_PRICES } from './mocks/spot-prices'; +import { SpotPricesStruct } from './types'; + +describe('structs', () => { + describe('SpotPricesFromPriceApiWithIncludeMarketDataFalseStruct', () => { + it('should validate the struct', () => { + const spotPrices = MOCK_SPOT_PRICES; + + expect(() => assert(spotPrices, SpotPricesStruct)).not.toThrow(); + }); + + it('should reject invalid spot prices', () => { + const spotPricesWithInvalidPrice = cloneDeep(MOCK_SPOT_PRICES); + spotPricesWithInvalidPrice[ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' + ]!.price = -4; + + expect(() => + assert(spotPricesWithInvalidPrice, SpotPricesStruct), + ).toThrow( + 'At path: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501.price -- Expected a number greater than or equal to 0 but received `-4`', + ); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/types.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/types.ts new file mode 100644 index 00000000..40ce9dc8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/types.ts @@ -0,0 +1,228 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + enums, + min, + nullable, + number, + object, + optional, + pattern, + record, + string, + tuple, + union, +} from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +export type PriceApiClientConfig = { + baseUrl: string; +}; + +export const CryptoTickerStruct = enums([ + 'btc', + 'eth', + 'ltc', + 'bch', + 'bnb', + 'eos', + 'xrp', + 'xlm', + 'link', + 'dot', + 'yfi', + 'bits', + 'sats', + 'sol', + 'sei', + 'sonic', +]); + +export const FiatTickerStruct = enums([ + 'usd', + 'aed', + 'amd', + 'ars', + 'aud', + 'bam', + 'bdt', + 'bhd', + 'bmd', + 'brl', + 'cad', + 'chf', + 'clp', + 'cny', + 'cop', + 'crc', + 'czk', + 'dkk', + 'dop', + 'eur', + 'gbp', + 'gel', + 'gtq', + 'hkd', + 'hnl', + 'huf', + 'idr', + 'ils', + 'inr', + 'jpy', + 'kes', + 'krw', + 'kwd', + 'lbp', + 'lkr', + 'mmk', + 'mxn', + 'myr', + 'ngn', + 'nok', + 'nzd', + 'pen', + 'php', + 'pkr', + 'pln', + 'ron', + 'rub', + 'sar', + 'sek', + 'sgd', + 'svc', + 'thb', + 'try', + 'twd', + 'uah', + 'vef', + 'vnd', + 'xdr', + 'zar', + 'zmw', +]); + +export const CommodityTickerStruct = enums(['xag', 'xau']); + +export type CryptoTicker = Infer; +export type FiatTicker = Infer; +export type CommodityTicker = Infer; + +export const TickerStruct = union([ + CryptoTickerStruct, + FiatTickerStruct, + CommodityTickerStruct, +]); + +export type Ticker = Infer; + +export type ExchangeRate = { + name: string; + ticker: Ticker; + value: number; + currencyType: 'fiat' | 'crypto' | 'commodity'; +}; + +/** + * The structure of the spot price response from the Price API as described in + * [this file](https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/types/price.ts#L46-L71). + * + * For safety, most fields are marked optional and nullable even though it goes against the type in the Price API source code. + */ + +export const SpotPriceStruct = object({ + id: string(), + price: min(number(), 0), + marketCap: optional(nullable(min(number(), 0))), + allTimeHigh: optional(nullable(min(number(), 0))), + allTimeLow: optional(nullable(min(number(), 0))), + totalVolume: optional(nullable(min(number(), 0))), + high1d: optional(nullable(min(number(), 0))), + low1d: optional(nullable(min(number(), 0))), + circulatingSupply: optional(nullable(min(number(), 0))), + dilutedMarketCap: optional(nullable(min(number(), 0))), + marketCapPercentChange1d: optional(nullable(number())), + priceChange1d: optional(nullable(number())), + pricePercentChange1h: optional(nullable(number())), + pricePercentChange1d: optional(nullable(number())), + pricePercentChange7d: optional(nullable(number())), + pricePercentChange14d: optional(nullable(number())), + pricePercentChange30d: optional(nullable(number())), + pricePercentChange200d: optional(nullable(number())), + pricePercentChange1y: optional(nullable(number())), + bondingCurveProgressPercent: optional(nullable(number())), + liquidity: optional(nullable(number())), + totalSupply: optional(nullable(number())), + holderCount: optional(nullable(number())), + isMutable: optional(nullable(boolean())), +}); + +export type SpotPrice = Infer; + +/** + * @example + * { + * "bip122:000000000019d6689c085ae165831e93/slip44:0": { + * "id": "bitcoin", + * "price": 84302, + * "marketCap": 1670808919774, + * "allTimeHigh": 108786, + * "allTimeLow": 67.81, + * "totalVolume": 25784747348, + * "high1d": 84370, + * "low1d": 81426, + * "circulatingSupply": 19844840, + * "dilutedMarketCap": 1670808919774, + * "marketCapPercentChange1d": 3.2788, + * "priceChange1d": 2876.1, + * "pricePercentChange1h": 0.1991278666784771, + * "pricePercentChange1d": 3.5321815522315307, + * "pricePercentChange7d": -3.4056070943823666, + * "pricePercentChange14d": 1.663812725054475, + * "pricePercentChange30d": -1.8166338283570667, + * "pricePercentChange200d": 45.12491105880435, + * "pricePercentChange1y": 21.403818710804778 + * }, + * "eip155:1/slip44:60": { ... }, + * "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501": null + */ +export const SpotPricesStruct = record( + CaipAssetTypeStruct, + nullable(SpotPriceStruct), +); + +export type SpotPrices = Infer; + +// In the Price API source code, the parameters `vsCurrency` and `ticker` represent the same list of values. +// We create aliases here for clarity. +export const VsCurrencyParamStruct = TickerStruct; +export type VsCurrencyParam = Infer; + +export const GetHistoricalPricesParamsStruct = object({ + assetType: CaipAssetTypeStruct, + timePeriod: optional(pattern(string(), /^[1-9][0-9]*[dmy]$/u)), // Supports days, months, years + from: optional(min(number(), 0)), + to: optional(min(number(), 0)), + vsCurrency: optional(VsCurrencyParamStruct), +}); + +export type GetHistoricalPricesParams = Infer< + typeof GetHistoricalPricesParamsStruct +>; + +export const GetHistoricalPricesResponseStruct = object({ + prices: array(tuple([number(), number()])), + marketCaps: array(tuple([number(), number()])), + totalVolumes: array(tuple([number(), number()])), +}); + +export type GetHistoricalPricesResponse = Infer< + typeof GetHistoricalPricesResponseStruct +>; + +export const GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT: GetHistoricalPricesResponse = + { + prices: [], + marketCaps: [], + totalVolumes: [], + }; diff --git a/merged-packages/tron-wallet-snap/src/constants/tron.ts b/merged-packages/tron-wallet-snap/src/constants/tron.ts new file mode 100644 index 00000000..d7295bf4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/constants/tron.ts @@ -0,0 +1,65 @@ +import { TrxScope } from "@metamask/keyring-api"; + +export enum Network { + Mainnet = TrxScope.Mainnet, + Nile = TrxScope.Nile, + Shasta = TrxScope.Shasta, + Localnet = 'tron:localnet', +} + +export enum KnownCaip19Id { + TrxMainnet = `${Network.Mainnet}/slip44:195`, + TrxNile = `${Network.Nile}/slip44:195`, + TrxShasta = `${Network.Shasta}/slip44:195`, + TrxLocalnet = `${Network.Localnet}/slip44:195`, +} + +export const TokenMetadata = { + [KnownCaip19Id.TrxMainnet]: { + name: 'Tron', + symbol: 'TRX', + decimals: 6, + }, + [KnownCaip19Id.TrxNile]: { + name: 'Tron', + symbol: 'TRX', + decimals: 6, + }, + [KnownCaip19Id.TrxShasta]: { + name: 'Tron', + symbol: 'TRX', + decimals: 6, + }, + [KnownCaip19Id.TrxLocalnet]: { + name: 'Tron', + symbol: 'TRX', + decimals: 6, + }, +} as const; + +export const Networks = { + [Network.Mainnet]: { + caip2Id: Network.Mainnet, + cluster: 'mainnet', + name: 'Tron Mainnet', + nativeToken: TokenMetadata[KnownCaip19Id.TrxMainnet], + }, + [Network.Nile]: { + caip2Id: Network.Nile, + cluster: 'devnet', + name: 'Tron Nile', + nativeToken: TokenMetadata[KnownCaip19Id.TrxNile], + }, + [Network.Shasta]: { + caip2Id: Network.Shasta, + cluster: 'testnet', + name: 'Tron Shasta', + nativeToken: TokenMetadata[KnownCaip19Id.TrxShasta], + }, + [Network.Localnet]: { + caip2Id: Network.Localnet, + cluster: 'local', + name: 'Tron Localnet', + nativeToken: TokenMetadata[KnownCaip19Id.TrxLocalnet], + }, +} as const; \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts new file mode 100644 index 00000000..93d962d3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -0,0 +1,106 @@ +import { AssetsHandler } from "./handlers/assets"; +import { CronHandler } from "./handlers/cronjob"; +import { KeyringHandler } from "./handlers/keyring"; +import { RpcHandler } from "./handlers/rpc"; +import { UserInputHandler } from "./handlers/userInput"; +import { AssetsService } from "./services/assets/AssetsService"; +import { ConfigProvider } from "./services/config"; +import { State, UnencryptedStateValue } from "./services/state/State"; +import { TransactionsService } from "./services/transactions/TransactionsService"; +import { WalletService } from "./services/wallet/WalletService"; +import logger from "./utils/logger"; + +/** + * Services + */ +const configProvider = new ConfigProvider(); + +const state = new State({ + encrypted: false, + defaultState: { + keyringAccounts: {}, + assets: {}, + tokenPrices: {}, + transactions: {}, + }, +}); + +const assetsService = new AssetsService({ + logger: logger, + configProvider: configProvider, + state: state, +}); + +const transactionService = new TransactionsService({ + logger: logger, + state: state, +}); + +const walletService = new WalletService({ + logger: logger, + state: state, +}); + +/** + * Handlers + */ +const assetsHandler = new AssetsHandler(); +const cronHandler = new CronHandler(); +const keyringHandler = new KeyringHandler({ + logger: logger, + state: state, + assetsService: assetsService, + transactionService: transactionService, + walletService: walletService, +}); +const rpcHandler = new RpcHandler(); +const userInputHandler = new UserInputHandler(); + +export type SnapExecutionContext = { + /** + * Services + */ + state: State; + assetsService: AssetsService; + transactionService: TransactionsService; + walletService: WalletService; + /** + * Handlers + */ + assetsHandler: AssetsHandler; + cronHandler: CronHandler; + keyringHandler: KeyringHandler; + rpcHandler: RpcHandler; + userInputHandler: UserInputHandler; +} + +const snapContext: SnapExecutionContext = { + /** + * Services + */ + state, + assetsService, + transactionService, + walletService, + /** + * Handlers + */ + assetsHandler, + cronHandler, + keyringHandler, + rpcHandler, + userInputHandler +}; + +export { + /** + * Handlers + */ + assetsHandler, + cronHandler, + keyringHandler, + rpcHandler, + userInputHandler +}; + +export default snapContext; diff --git a/merged-packages/tron-wallet-snap/src/entities/index.ts b/merged-packages/tron-wallet-snap/src/entities/index.ts new file mode 100644 index 00000000..1640455c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/entities/index.ts @@ -0,0 +1 @@ +export * from './keyring-account'; diff --git a/merged-packages/tron-wallet-snap/src/entities/keyring-account.ts b/merged-packages/tron-wallet-snap/src/entities/keyring-account.ts new file mode 100644 index 00000000..b3c0b24d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/entities/keyring-account.ts @@ -0,0 +1,31 @@ +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; + +/** + * We need to store the index of the KeyringAccount in the state because + * we want to be able to restore any account with a previously used index. + */ +export type TronKeyringAccount = KeyringAccount & { + entropySource: EntropySourceId; + derivationPath: `m/${string}`; + index: number; +}; + +/** + * Converts a Tron keyring account to its stricter form (required by the Keyring API). + * + * @param account - A Tron keyring account. + * @returns A strict keyring account (with no additional fields). + */ +export function asStrictKeyringAccount( + account: TronKeyringAccount, +): KeyringAccount { + const { id, address, type, options, methods, scopes } = account; + return { + id, + address, + type, + options, + methods, + scopes, + }; +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts new file mode 100644 index 00000000..b5c79450 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -0,0 +1,41 @@ +import type { + OnAssetHistoricalPriceArguments, + OnAssetHistoricalPriceResponse, + OnAssetsConversionArguments, + OnAssetsConversionResponse, + OnAssetsLookupArguments, + OnAssetsLookupResponse, + OnAssetsMarketDataArguments, + OnAssetsMarketDataResponse, +} from '@metamask/snaps-sdk'; + +export class AssetsHandler { + async onAssetHistoricalPrice( + params: OnAssetHistoricalPriceArguments, + ): Promise { + return { + historicalPrice: { + intervals: {}, + updateTime: Date.now(), + }, + }; + } + + async onAssetsConversion( + conversions: OnAssetsConversionArguments, + ): Promise { + return { conversionRates: {} }; + } + + async onAssetsLookup( + params: OnAssetsLookupArguments, + ): Promise { + return { assets: {} }; + } + + async onAssetsMarketData( + assets: OnAssetsMarketDataArguments, + ): Promise { + return { marketData: {} }; + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts new file mode 100644 index 00000000..25e3e655 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts @@ -0,0 +1,14 @@ +import type { JsonRpcRequest } from '@metamask/utils'; + +export class CronHandler { + async handle({ + request, + }: { + request: JsonRpcRequest; + }): Promise { + /** + * Map cronjob to the appropriate handler + */ + // TODO: No cronjobs yet + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts new file mode 100644 index 00000000..dd21119d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -0,0 +1,322 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + KeyringEvent, + TrxAccountType, + TrxScope, + type Balance, + type DiscoveredAccount, + type EntropySourceId, + type Keyring, + type KeyringAccount, + type KeyringAccountData, + type KeyringRequest, + type KeyringResponse, + type MetaMaskOptions, + type Paginated, + type Pagination, + type ResolvedAccountAddress, + type Transaction, +} from '@metamask/keyring-api'; +import { + emitSnapKeyringEvent, + handleKeyringRequest, +} from '@metamask/keyring-snap-sdk'; +import { assert, integer } from '@metamask/superstruct'; +import type { + CaipAssetType, + CaipAssetTypeOrId, + CaipChainId, + Json, + JsonRpcRequest, +} from '@metamask/utils'; + +import type { TronKeyringAccount } from '../entities'; +import { asStrictKeyringAccount } from '../entities/keyring-account'; +import { AssetsService } from '../services/assets/AssetsService'; +import { State, UnencryptedStateValue } from '../services/state/State'; +import { TransactionsService } from '../services/transactions/TransactionsService'; +import { WalletService } from '../services/wallet/WalletService'; +import { deriveTronKeypair } from '../utils/deriveTronKeypair'; +import { withCatchAndThrowSnapError } from '../utils/errors'; +import { getLowestUnusedIndex } from '../utils/getLowestUnusedIndex'; +import { listEntropySources } from '../utils/interface'; +import type { ILogger } from '../utils/logger'; +import { validateOrigin } from '../validation/validators'; + +export class KeyringHandler implements Keyring { + readonly #logger: ILogger; + + readonly #state: State; + + constructor({ + logger, + state, + assetsService, + transactionService, + walletService, + }: { + logger: ILogger; + state: State; + assetsService: AssetsService; + transactionService: TransactionsService; + walletService: WalletService; + }) { + this.#logger = logger; + this.#state = state; + } + + async handle(origin: string, request: JsonRpcRequest): Promise { + validateOrigin(origin, request.method); + + const result = + (await withCatchAndThrowSnapError(async () => + handleKeyringRequest(this, request), + )) ?? null; + + return result; + } + + async listAccounts(): Promise { + throw new Error('Method not implemented.'); + } + + async getAccount(id: string): Promise { + throw new Error('Method not implemented.'); + } + + #getLowestUnusedKeyringAccountIndex( + accounts: TronKeyringAccount[], + entropySource: EntropySourceId, + ): number { + const accountsFilteredByEntropySourceId = accounts.filter( + (account) => account.entropySource === entropySource, + ); + + return getLowestUnusedIndex(accountsFilteredByEntropySourceId); + } + + #getDefaultDerivationPath(index: number): `m/${string}` { + return `m/44'/195'/${index}'/0/0`; + } + + #getIndexFromDerivationPath(derivationPath: `m/${string}`): number { + const levels = derivationPath.split('/'); + const indexLevel = levels[3]; + + if (!indexLevel) { + throw new Error('Invalid derivation path'); + } + + const index = parseInt(indexLevel.replace("'", ''), 10); + assert(index, integer()); + + return index; + } + + async #getDefaultEntropySource(): Promise { + const entropySources = await listEntropySources(); + const defaultEntropySource = entropySources.find(({ primary }) => primary); + + if (!defaultEntropySource) { + throw new Error( + 'No default entropy source found - this can never happen', + ); + } + + return defaultEntropySource.id; + } + + async #deleteAccountFromState(accountId: string): Promise { + await Promise.all([ + this.#state.deleteKey(`keyringAccounts.${accountId}`), + this.#state.deleteKey(`transactions.${accountId}`), + this.#state.deleteKey(`assets.${accountId}`), + ]); + } + + async createAccount( + options?: { + entropySource?: EntropySourceId; + derivationPath?: `m/${string}`; + accountNameSuggestion?: string; + [key: string]: Json | undefined; + } & MetaMaskOptions, + ): Promise { + const id = globalThis.crypto.randomUUID(); + + try { + const accounts = await this.listAccounts(); + + const entropySource = + options?.entropySource ?? (await this.#getDefaultEntropySource()); + + const index = options?.derivationPath + ? this.#getIndexFromDerivationPath(options.derivationPath) + : this.#getLowestUnusedKeyringAccountIndex(accounts, entropySource); + + const derivationPath = + options?.derivationPath ?? this.#getDefaultDerivationPath(index); + + /** + * Now that we have the `entropySource` and `derivationPath` ready, + * we need to make sure that they do not correspond to an existing account already. + */ + const sameAccount = accounts.find( + (account) => + account.derivationPath === derivationPath && + account.entropySource === entropySource, + ); + + if (sameAccount) { + this.#logger.warn( + '[🔑 Keyring] An account already exists with the same derivation path and entropy source. Skipping account creation.', + ); + return asStrictKeyringAccount(sameAccount); + } + + const { publicKeyBytes } = await deriveTronKeypair({ + entropySource, + derivationPath, + }); + + const accountAddress = 'TT2T17KZhoDu47i2E4FWxfG79zdkEWkU9N'; // TODO: Replace this mock + + // Filter out our special properties from options + const { + importedAccount, + accountNameSuggestion, + metamask: metamaskOptions, + ...remainingOptions + } = options ?? {}; + + const solanaKeyringAccount: TronKeyringAccount = { + id, + entropySource, + derivationPath, + index, + type: TrxAccountType.Eoa, + address: accountAddress, + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + ...remainingOptions, + /** + * Make sure to save the `entropySource`, `derivationPath` and `index` + * in the keyring account options.. + */ + entropySource, + derivationPath, + index, + }, + methods: ['signMessageV2', 'verifyMessageV2'], + }; + + await this.#state.setKey( + `keyringAccounts.${solanaKeyringAccount.id}`, + solanaKeyringAccount, + ); + + const keyringAccount = asStrictKeyringAccount(solanaKeyringAccount); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { + /** + * We can't pass the `keyringAccount` object because it contains the index + * and the snaps sdk does not allow extra properties. + */ + account: keyringAccount, + accountNameSuggestion: + accountNameSuggestion ?? `Tron Account ${index + 1}`, + displayAccountNameSuggestion: !accountNameSuggestion, + /** + * Skip account creation confirmation dialogs to make it look like a native + * account creation flow. + */ + displayConfirmation: false, + /** + * Internal options to MetaMask that includes a correlation ID. We need + * to also emit this ID to the Snap keyring. + */ + ...(metamaskOptions + ? { + metamask: metamaskOptions, + } + : {}), + }); + + return keyringAccount; + } catch (error: any) { + this.#logger.error({ error }, 'Error creating account'); + await this.#deleteAccountFromState(id); + + throw new Error(`Error creating account: ${error.message}`); + } + } + + listAccountAssets?(id: string): Promise { + throw new Error('Method not implemented.'); + } + + listAccountTransactions?( + id: string, + pagination: Pagination, + ): Promise> { + throw new Error('Method not implemented.'); + } + + discoverAccounts?( + scopes: CaipChainId[], + entropySource: EntropySourceId, + groupIndex: number, + ): Promise { + throw new Error('Method not implemented.'); + } + + getAccountBalances?( + id: string, + assets: CaipAssetType[], + ): Promise> { + throw new Error('Method not implemented.'); + } + + resolveAccountAddress?( + scope: CaipChainId, + request: JsonRpcRequest, + ): Promise { + throw new Error('Method not implemented.'); + } + + async filterAccountChains(id: string, chains: string[]): Promise { + throw new Error('Method not implemented.'); + } + + async updateAccount(account: KeyringAccount): Promise { + throw new Error('Method not implemented.'); + } + + async deleteAccount(id: string): Promise { + throw new Error('Method not implemented.'); + } + + exportAccount?(id: string): Promise { + throw new Error('Method not implemented.'); + } + + listRequests?(): Promise { + throw new Error('Method not implemented.'); + } + + getRequest?(id: string): Promise { + throw new Error('Method not implemented.'); + } + + async submitRequest(request: KeyringRequest): Promise { + throw new Error('Method not implemented.'); + } + + approveRequest?(id: string, data?: Record): Promise { + throw new Error('Method not implemented.'); + } + + rejectRequest?(id: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/rpc.ts b/merged-packages/tron-wallet-snap/src/handlers/rpc.ts new file mode 100644 index 00000000..2079e581 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/rpc.ts @@ -0,0 +1,18 @@ +import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; + +import { validateOrigin } from '../validation/validators'; + +export class RpcHandler { + async handle( + origin: string, + request: JsonRpcRequest, + ): Promise { + validateOrigin(origin, request.method); + + return { + id: request.id, + jsonrpc: request.jsonrpc, + result: null, + }; + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/userInput.ts b/merged-packages/tron-wallet-snap/src/handlers/userInput.ts new file mode 100644 index 00000000..ba1a07ce --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/userInput.ts @@ -0,0 +1,18 @@ +import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; + +export class UserInputHandler { + async handle({ + id, + event, + context, + }: { + id: string; + event: UserInputEvent; + context: InterfaceContext | null; + }): Promise { + /** + * Map user input to the appropriate handler + */ + // TODO: No user input yet + } +} diff --git a/merged-packages/tron-wallet-snap/src/index.ts b/merged-packages/tron-wallet-snap/src/index.ts new file mode 100644 index 00000000..c25815ac --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/index.ts @@ -0,0 +1,42 @@ +import type { + OnAssetHistoricalPriceHandler, + OnAssetsConversionHandler, + OnAssetsLookupHandler, + OnAssetsMarketDataHandler, + OnCronjobHandler, + OnKeyringRequestHandler, + OnRpcRequestHandler, + OnUserInputHandler, +} from '@metamask/snaps-sdk'; +import { assetsHandler, cronHandler, keyringHandler, rpcHandler, userInputHandler } from './context'; + +/** + * Register all handlers + */ + +export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( + args, +) => assetsHandler.onAssetHistoricalPrice(args); + +export const onAssetsConversion: OnAssetsConversionHandler = async (args) => + assetsHandler.onAssetsConversion(args); + +export const onAssetsLookup: OnAssetsLookupHandler = async (args) => + assetsHandler.onAssetsLookup(args); + +export const onAssetsMarketData: OnAssetsMarketDataHandler = async (args) => + assetsHandler.onAssetsMarketData(args); + +export const onCronjob: OnCronjobHandler = async (args) => + cronHandler.handle(args); + +export const onKeyringRequest: OnKeyringRequestHandler = async ({ + origin, + request, +}) => keyringHandler.handle(origin, request); + +export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => + rpcHandler.handle(origin, request); + +export const onUserInput: OnUserInputHandler = async (params) => + userInputHandler.handle(params); diff --git a/merged-packages/tron-wallet-snap/src/permissions.ts b/merged-packages/tron-wallet-snap/src/permissions.ts new file mode 100644 index 00000000..8b01fc12 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/permissions.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/only-throw-error */ +import { KeyringRpcMethod } from '@metamask/keyring-api'; + +const prodOrigins = ['https://portfolio.metamask.io']; + +const isDev = true; // TODO: Change me when we have a config provider +const allowedOrigins = isDev ? ['http://localhost:3000'] : prodOrigins; + +const dappPermissions = isDev + ? new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.FilterAccountChains, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.DiscoverAccounts, + KeyringRpcMethod.GetAccountBalances, + KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, + ]) + : new Set([]); + +const metamaskPermissions = new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.DiscoverAccounts, + KeyringRpcMethod.GetAccountBalances, + KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, + KeyringRpcMethod.ResolveAccountAddress, +]); + +const metamask = 'metamask'; + +export const originPermissions = new Map>([]); + +for (const origin of allowedOrigins) { + originPermissions.set(origin, dappPermissions); +} +originPermissions.set(metamask, metamaskPermissions); diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts new file mode 100644 index 00000000..cb689bfb --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -0,0 +1,149 @@ +import { AssetMetadata, FungibleAssetMetadata, NonFungibleAssetMetadata } from "@metamask/snaps-sdk"; +import { CaipAssetType, parseCaipAssetType } from "@metamask/utils"; +import { uniq } from "lodash"; +import { TronKeyringAccount } from "../../entities"; +import { ILogger } from "../../utils/logger"; +import { ConfigProvider } from "../config"; +import { State, UnencryptedStateValue } from "../state/State"; +import { NativeCaipAssetType, NftCaipAssetType, TokenCaipAssetType } from "./types"; + +export class AssetsService { + readonly #logger: ILogger; + + readonly #loggerPrefix = '[🪙 AssetsService]'; + + readonly #configProvider: ConfigProvider; + + readonly #state: State; + + constructor({ logger, configProvider, state }: { logger: ILogger, configProvider: ConfigProvider, state: State }) { + this.#logger = logger; + this.#configProvider = configProvider; + this.#state = state; + } + + async #listAddressNativeAssets(address: string): Promise { + return []; // TODO: Implement me + } + + async #listAddressTokenAssets( + address: string, + ): Promise { + return []; // TODO: Implement me + } + + async #listAddressNftAssets(address: string): Promise { + return []; // TODO: Implement me + } + + async listAccountAssets( + account: TronKeyringAccount, + ): Promise { + this.#logger.log( + this.#loggerPrefix, + 'Fetching all assets for account', + account, + ); + + const accountAddress = account.address + + const [ + nativeAssetsIds, + tokenAssetsIds, + nftAssetsIds + ] = await Promise.all([ + this.#listAddressNativeAssets(accountAddress), + this.#listAddressTokenAssets(accountAddress), + this.#listAddressNftAssets(accountAddress), + ]); + + return uniq([ + ...nativeAssetsIds, + ...tokenAssetsIds, + ...nftAssetsIds + ]); + } + + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + this.#logger.log( + this.#loggerPrefix, + 'Fetching metadata for assets', + assetTypes, + ); + + const { nativeAssetTypes, tokenAssetTypes, nftAssetTypes } = + this.#splitAssetsByType(assetTypes); + + const [ + nativeTokensMetadata, + tokensMetadata, + nftMetadata, + ] = await Promise.all([ + this.getNativeTokensMetadata(nativeAssetTypes), + this.getTokensMetadata(tokenAssetTypes), + this.getNftsMetadata(nftAssetTypes), + ]); + + return { + ...nativeTokensMetadata, + ...tokensMetadata, + ...nftMetadata, + }; + } + + #splitAssetsByType(assetTypes: CaipAssetType[]) { + const nativeAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('slip44:195'), + ) as NativeCaipAssetType[]; + const tokenAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc10:') || assetType.includes('/trc20:'), + ) as TokenCaipAssetType[]; + const nftAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc721'), + ) as NftCaipAssetType[]; + + return { nativeAssetTypes, tokenAssetTypes, nftAssetTypes }; + } + + getNativeTokensMetadata( + assetTypes: NativeCaipAssetType[], + ): Record { + const nativeTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + const { chainId } = parseCaipAssetType(assetType); + nativeTokensMetadata[assetType] = { + name: 'TRON', + symbol: 'TRX', + fungible: true, + iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, + units: [ + { + name: 'TRON', + symbol: 'SOL', + decimals: 9, + }, + ], + }; + } + + return nativeTokensMetadata; + } + + async getTokensMetadata( + assetTypes: TokenCaipAssetType[], + ): Promise> { + return {}; // TODO: Implement me + } + + async getNftsMetadata( + assetTypes: NftCaipAssetType[], + ): Promise> { + return {}; // TODO: Implement me + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts new file mode 100644 index 00000000..a55f3407 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -0,0 +1,30 @@ +import { TrxScope } from "@metamask/keyring-api"; +import { pattern, string } from "@metamask/superstruct"; + +export type NativeCaipAssetType = `${TrxScope}/slip44:195`; +export type TokenCaipAssetType = `${TrxScope}/${'trc10' | 'trc20'}:${string}`; +export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; + + /** + * Validates a TRON native CAIP-19 ID (e.g., "tron:mainnet/slip44:195") + */ +export const NativeCaipAssetTypeStruct = pattern( + string(), + /^tron:(mainnet|nile|shasta)\/slip44:195$/u, +); + +/** + * Validates a TRON token CAIP-19 ID (e.g., "tron:mainnet/trc10:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t") + */ +export const TokenCaipAssetTypeStruct = pattern( + string(), + /^tron:(mainnet|nile|shasta)\/(trc10|trc20):[a-zA-Z0-9]+$/u, +); + +/** + * Validates a TRON NFT CAIP-19 ID (e.g., "tron:mainnet/trc721:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + */ +export const NftCaipAssetTypeStruct = pattern( + string(), + /^tron:(mainnet|nile|shasta)\/trc721:[a-zA-Z0-9]+$/u, +); \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts new file mode 100644 index 00000000..8a9d98ac --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -0,0 +1,229 @@ +/* eslint-disable no-restricted-globals */ +import type { Infer } from '@metamask/superstruct'; +import { + array, + coerce, + create, + enums, + object, + string, +} from '@metamask/superstruct'; +import { Duration } from '@metamask/utils'; + +import { Network, Networks } from '../../constants/tron'; +import { UrlStruct } from '../../validation/structs'; + +const ENVIRONMENT_TO_ACTIVE_NETWORKS = { + production: [Network.Mainnet], + local: [Network.Mainnet, Network.Nile, Network.Shasta], + test: [Network.Localnet], +}; + +const CommaSeparatedListOfUrlsStruct = coerce( + array(UrlStruct), + string(), + (value: string) => value.split(','), +); + +const CommaSeparatedListOfStringsStruct = coerce( + array(string()), + string(), + (value: string) => value.split(','), +); + +const EnvStruct = object({ + ENVIRONMENT: enums(['local', 'test', 'production']), + RPC_URL_MAINNET_LIST: CommaSeparatedListOfUrlsStruct, + RPC_URL_DEVNET_LIST: CommaSeparatedListOfUrlsStruct, + RPC_URL_TESTNET_LIST: CommaSeparatedListOfUrlsStruct, + RPC_URL_LOCALNET_LIST: CommaSeparatedListOfStringsStruct, + RPC_WEB_SOCKET_URL_MAINNET: UrlStruct, + RPC_WEB_SOCKET_URL_DEVNET: UrlStruct, + RPC_WEB_SOCKET_URL_TESTNET: UrlStruct, + RPC_WEB_SOCKET_URL_LOCALNET: UrlStruct, + EXPLORER_BASE_URL: UrlStruct, + PRICE_API_BASE_URL: UrlStruct, + TOKEN_API_BASE_URL: UrlStruct, + STATIC_API_BASE_URL: UrlStruct, + SECURITY_ALERTS_API_BASE_URL: UrlStruct, + NFT_API_BASE_URL: UrlStruct, + LOCAL_API_BASE_URL: string(), +}); + +export type Env = Infer; + +export type NetworkConfig = (typeof Networks)[Network] & { + rpcUrls: string[]; + webSocketUrl: string; +}; + +export type Config = { + environment: string; + networks: NetworkConfig[]; + activeNetworks: Network[]; + explorerBaseUrl: string; + priceApi: { + baseUrl: string; + chunkSize: number; + cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + }; + tokenApi: { + baseUrl: string; + chunkSize: number; + }; + staticApi: { + baseUrl: string; + }; + transactions: { + storageLimit: number; + }; + securityAlertsApi: { + baseUrl: string; + }; + nftApi: { + baseUrl: string; + cacheTtlsMilliseconds: { + listAddressSolanaNfts: number; + getNftMetadata: number; + }; + }; + subscription: { + maxReconnectAttempts: number; + reconnectDelayMilliseconds: number; + }; +}; + +/** + * A utility class that provides the configuration of the snap. + * + * @example + * const configProvider = new ConfigProvider(); + * const { networks } = configProvider.get(); + * @example + * // You can use utility methods for more advanced manipulations. + * const network = configProvider.getNetworkBy('caip2Id', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + */ +export class ConfigProvider { + #config: Config; + + constructor() { + const environment = this.#parseEnvironment(); + this.#config = this.#buildConfig(environment); + } + + #parseEnvironment() { + const rawEnvironment = { + ENVIRONMENT: process.env.ENVIRONMENT, + RPC_URL_MAINNET_LIST: process.env.RPC_URL_MAINNET_LIST, + RPC_URL_DEVNET_LIST: process.env.RPC_URL_DEVNET_LIST, + RPC_URL_TESTNET_LIST: process.env.RPC_URL_TESTNET_LIST, + RPC_URL_LOCALNET_LIST: process.env.RPC_URL_LOCALNET_LIST, + RPC_WEB_SOCKET_URL_MAINNET: process.env.RPC_WEB_SOCKET_URL_MAINNET, + RPC_WEB_SOCKET_URL_DEVNET: process.env.RPC_WEB_SOCKET_URL_DEVNET, + RPC_WEB_SOCKET_URL_TESTNET: process.env.RPC_WEB_SOCKET_URL_TESTNET, + RPC_WEB_SOCKET_URL_LOCALNET: process.env.RPC_WEB_SOCKET_URL_LOCALNET, + EXPLORER_BASE_URL: process.env.EXPLORER_BASE_URL, + PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL, + TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL, + STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL, + SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL, // Blockaid + LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL, + // NFT API + NFT_API_BASE_URL: process.env.NFT_API_BASE_URL, + }; + + // Validate and parse them before returning + return create(rawEnvironment, EnvStruct); + } + + #buildConfig(environment: Env): Config { + return { + environment: environment.ENVIRONMENT, + networks: [ + { + ...Networks[Network.Mainnet], + rpcUrls: environment.RPC_URL_MAINNET_LIST, + webSocketUrl: environment.RPC_WEB_SOCKET_URL_MAINNET, + }, + { + ...Networks[Network.Nile], + rpcUrls: environment.RPC_URL_DEVNET_LIST, + webSocketUrl: environment.RPC_WEB_SOCKET_URL_DEVNET, + }, + { + ...Networks[Network.Shasta], + rpcUrls: environment.RPC_URL_TESTNET_LIST, + webSocketUrl: environment.RPC_WEB_SOCKET_URL_TESTNET, + }, + { + ...Networks[Network.Localnet], + rpcUrls: environment.RPC_URL_LOCALNET_LIST, + webSocketUrl: environment.RPC_WEB_SOCKET_URL_LOCALNET, + }, + ], + explorerBaseUrl: environment.EXPLORER_BASE_URL, + activeNetworks: ENVIRONMENT_TO_ACTIVE_NETWORKS[environment.ENVIRONMENT], + priceApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.PRICE_API_BASE_URL, + chunkSize: 50, + cacheTtlsMilliseconds: { + fiatExchangeRates: Duration.Minute, + spotPrices: Duration.Minute, + historicalPrices: Duration.Minute, + }, + }, + tokenApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.TOKEN_API_BASE_URL, + chunkSize: 50, + }, + staticApi: { + baseUrl: environment.STATIC_API_BASE_URL, + }, + transactions: { + storageLimit: 10, + }, + securityAlertsApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.SECURITY_ALERTS_API_BASE_URL, + }, + nftApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.NFT_API_BASE_URL, + cacheTtlsMilliseconds: { + listAddressSolanaNfts: Duration.Minute, + getNftMetadata: Duration.Minute, + }, + }, + subscription: { + maxReconnectAttempts: 5, + reconnectDelayMilliseconds: Duration.Second, + }, + }; + } + + public get(): Config { + return this.#config; + } + + public getNetworkBy(key: keyof NetworkConfig, value: string): NetworkConfig { + const network = this.get().networks.find((item) => item[key] === value); + if (!network) { + throw new Error(`Network ${key} not found`); + } + return network; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/config/index.ts b/merged-packages/tron-wallet-snap/src/services/config/index.ts new file mode 100644 index 00000000..6564831c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/config/index.ts @@ -0,0 +1 @@ +export { ConfigProvider } from './ConfigProvider'; diff --git a/merged-packages/tron-wallet-snap/src/services/state/IStateManager.ts b/merged-packages/tron-wallet-snap/src/services/state/IStateManager.ts new file mode 100644 index 00000000..a0df79f5 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/state/IStateManager.ts @@ -0,0 +1,85 @@ +import type { Serializable } from '../../utils/serialization/types'; + +export type IStateManager> = { + /** + * Gets the whole state object. + * + * ⚠️ WARNING: Use with caution because it transfers the whole state, which might contain a lot of data. + * If you need to retrieve only a specific part of the state, use IStateManager.getKey instead. + * + * @example + * ```typescript + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * + * const value = await stateManager.get(); + * // value is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * ``` + */ + get(): Promise; + /** + * Gets the value of passed key in the state object. + * The key is the json path to the value to get. + * + * @example + * ```typescript + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * + * const value = await stateManager.getKey('users.1.name'); + * // value is 'Bob' + * + * @returns The value of the key, or undefined if the key does not exist. + */ + getKey( + key: string, + ): Promise; + /** + * Sets the value of passed key in the state object. + * The key is a json path to the value to set. + * + * @example + * ```typescript + * const state = await stateManager.get(); + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } + * + * await stateManager.set('users.1.name', 'John'); + * // state is now { users: [ { name: 'Alice', age: 20 }, { name: 'John', age: 25 } ] } + * ``` + * @param key - The key to set, which is a json path to the location. + * @param value - The value to set. + */ + setKey(key: string, value: any): Promise; + /** + * Updates the whole state object. + * + * Typically used for bulk `set`s or `delete`s, because: + * - Atomicity: Using a single `state.update` ensures that all changes are applied atomically. If any part of the operation fails, none of the changes will be applied. This prevents partial updates that could leave the underlying data store in an inconsistent state. + * - Performance: Making multiple individual `state.set` or `state.delete` calls would require multiple round trips to the state storage system, causing potential overheads. + * - State Consistency: Maintains better state consistency by reading the state once, making all modifications in memory and writing the complete updated state back. + * + * ⚠️ WARNING: Use with caution because: + * - it will override the whole state. + * - it transfers the whole state back and forth the data store, which might consume a lot of bandwidth. + * + * For single updates, use instead `setKey` or `deleteKey`. + * + * @param updaterFunction - The function that updates the state. + * @returns The updated state. + */ + update( + updaterFunction: (state: TStateValue) => TStateValue, + ): Promise; + /** + * Deletes the value of passed key in the state object. + * The key is a json path to the value to delete. + * + * @example + * ```typescript + * const state = await stateManager.get(); + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } + * + * await stateManager.deleteKey('users.1'); + * // state is now { users: [ { name: 'Alice', age: 20 } ] } + * ``` + */ + deleteKey(key: string): Promise; +}; diff --git a/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts b/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts new file mode 100644 index 00000000..06c176e5 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts @@ -0,0 +1,46 @@ +import { get, set, unset } from 'lodash'; + +import type { Serializable } from '../../utils/serialization/types'; +import type { IStateManager } from './IStateManager'; + +/** + * A simple implementation of the `IStateManager` interface that relies on an in memory state that can be used for testing purposes. + */ +export class InMemoryState> + implements IStateManager +{ + #state: TStateValue; + + constructor(initialState: TStateValue) { + this.#state = initialState; + } + + async get(): Promise { + return this.#state; + } + + async getKey( + key: string, + ): Promise { + const value = get(this.#state, key); + + return value as TResponse | undefined; + } + + async setKey(key: string, value: Serializable): Promise { + set(this.#state, key, value); // Use lodash to set the value using a json path + } + + async update( + callback: (state: TStateValue) => TStateValue, + ): Promise { + this.#state = callback(this.#state); + + return this.#state; + } + + async deleteKey(key: string): Promise { + // Using lodash's unset to leverage the json path capabilities + unset(this.#state, key); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.test.ts b/merged-packages/tron-wallet-snap/src/services/state/State.test.ts new file mode 100644 index 00000000..f68e6acf --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/state/State.test.ts @@ -0,0 +1,378 @@ +/* eslint-disable jest/prefer-strict-equal */ +/* eslint-disable @typescript-eslint/naming-convention */ +import BigNumber from 'bignumber.js'; + +import { State } from './State'; + +const snap = { + request: jest.fn(), +}; + +(globalThis as any).snap = snap; + +type User = { + name: string; + age: BigNumber | bigint | number | undefined | null; +}; + +type MockStateValue = { + users: User[]; +}; + +const DEFAULT_STATE: MockStateValue = { + users: [ + { + name: 'John', + age: 30, + }, + { + name: 'Jane', + age: 25, + }, + ], +}; + +describe('State', () => { + let state: State; + + beforeEach(() => { + state = new State({ + encrypted: false, + defaultState: DEFAULT_STATE, + }); + + jest.clearAllMocks(); + }); + + afterEach(() => { + snap.request.mockReset(); + }); + + describe('get', () => { + it('gets the state', async () => { + const mockUnderlyingState = DEFAULT_STATE; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { encrypted: false }, + }); + expect(stateValue).toStrictEqual(mockUnderlyingState); + }); + + it('gets the default state if the snap state is empty', async () => { + const mockUnderlyingState = {}; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual(DEFAULT_STATE); + }); + + describe('when getting serialized non-JSON values', () => { + it('deserializes undefined values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'undefined', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toEqual({ + users: [ + { + name: 'John', + age: undefined, + }, + ], + }); + }); + + it('deserializes BigNumber values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'BigNumber', + value: '30', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual({ + users: [ + { + name: 'John', + age: new BigNumber(30), + }, + ], + }); + }); + + it('deserializes bigint values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'bigint', + value: '30', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual({ + users: [ + { + name: 'John', + age: BigInt(30), + }, + ], + }); + }); + }); + }); + + describe('getKey', () => { + it('calls the snap_getState method with the correct parameters', async () => { + const mockUnderlyingState = DEFAULT_STATE; + snap.request.mockResolvedValue(mockUnderlyingState); + + await state.getKey('users.1.name'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { key: 'users.1.name', encrypted: false }, + }); + }); + + it('returns undefined if the key does not exist', async () => { + snap.request.mockResolvedValue(null); + + const value = await state.getKey('users.1.name'); + + expect(value).toBeUndefined(); + }); + }); + + describe('setKey', () => { + it('sets the value of a key', async () => { + await state.setKey('users.1.name', 'Bob'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_setState', + params: { + key: 'users.1.name', + value: 'Bob', + encrypted: false, + }, + }); + }); + }); + + describe('update', () => { + it('updates the state', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: 50, + }, + ], + })); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { encrypted: false }, + }); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: 50, + }, + ], + }, + }, + }); + }); + + describe('when updating serialized non-JSON values', () => { + it('serializes undefined values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: undefined, + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'undefined', + }, + }, + ], + }, + }, + }); + }); + + it('serializes BigNumber values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: new BigNumber(50), + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'BigNumber', + value: '50', + }, + }, + ], + }, + }, + }); + }); + + it('serializes bigint values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: BigInt(50), + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'bigint', + value: '50', + }, + }, + ], + }, + }, + }); + }); + + it('serializes null values', async () => { + await state.update((currentState) => ({ + users: [...currentState.users, { name: 'Bob', age: null }], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [...DEFAULT_STATE.users, { name: 'Bob', age: null }], + }, + }, + }); + }); + }); + }); + + describe('deleteKey', () => { + it('deletes a key', async () => { + await state.deleteKey('users'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: {}, + encrypted: false, + }, + }); + }); + + it('deletes a nested key', async () => { + await state.deleteKey('users[0].age'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: { + users: [ + { + name: 'John', + }, + { + name: 'Jane', + age: 25, + }, + ], + }, + encrypted: false, + }, + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.ts b/merged-packages/tron-wallet-snap/src/services/state/State.ts new file mode 100644 index 00000000..b68937fa --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/state/State.ts @@ -0,0 +1,133 @@ +import type { Balance, Transaction } from '@metamask/keyring-api'; +import type { CaipAssetType } from '@metamask/utils'; +import { unset } from 'lodash'; + +import type { SpotPrices } from '../../clients/price-api/types'; +import { TronKeyringAccount } from '../../entities'; +import { safeMerge } from '../../utils/safeMerge'; +import { deserialize } from '../../utils/serialization/deserialize'; +import { serialize } from '../../utils/serialization/serialize'; +import type { Serializable } from '../../utils/serialization/types'; +import type { IStateManager } from './IStateManager'; + +export type AccountId = string; + +export type UnencryptedStateValue = { + keyringAccounts: Record; + assets: Record>; + tokenPrices: SpotPrices; + transactions: Record; +}; + +export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { + keyringAccounts: {}, + assets: {}, + tokenPrices: {}, + transactions: {}, +}; + +export type StateConfig> = { + encrypted: boolean; + defaultState: TValue; +}; + +/** + * This class is a layer on top the the `snap_manageState` API that facilitates its usage: + * + * Basic usage: + * - Get and update the sate of the snap + * + * Serialization: + * - It serializes the data before storing it in the snap state because only JSON-assignable data can be stored. + * - It deserializes the data after retrieving it from the snap state. + * - So you don't need to worry about the data format when storing or retrieving data. + * + * Default values: + * - It merges the default state with the underlying snap state to ensure that we always have default values, + * letting us avoid a ton of null checks everywhere. + */ +export class State> + implements IStateManager +{ + readonly #config: StateConfig; + + constructor(config: StateConfig) { + this.#config = config; + } + + async get(): Promise { + const state = await snap.request({ + method: 'snap_getState', + params: { + encrypted: this.#config.encrypted, + }, + }); + + const stateDeserialized = deserialize(state ?? {}) as TStateValue; + + // Merge the default state with the underlying snap state + // to ensure that we always have default values. It lets us avoid a ton of null checks everywhere. + const stateWithDefaults = safeMerge( + this.#config.defaultState, + stateDeserialized, + ); + + return stateWithDefaults; + } + + async getKey( + key: string, + ): Promise { + const value = await snap.request({ + method: 'snap_getState', + params: { + key, + encrypted: this.#config.encrypted, + }, + }); + + if (value === null) { + return undefined; + } + + return deserialize(value) as TResponse; + } + + async setKey(key: string, value: Serializable): Promise { + await snap.request({ + method: 'snap_setState', + params: { + key, + value: serialize(value), + encrypted: this.#config.encrypted, + }, + }); + } + + async update( + updaterFunction: (state: TStateValue) => TStateValue, + ): Promise { + return this.get().then(async (state) => { + const newState = updaterFunction(state); + + await snap.request({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: serialize(newState), + encrypted: this.#config.encrypted, + }, + }); + + return newState; + }); + } + + async deleteKey(key: string): Promise { + await this.update((state) => { + // Using lodash's unset to leverage the json path capabilities + unset(state, key); + return state; + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts new file mode 100644 index 00000000..769fc398 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts @@ -0,0 +1,21 @@ +import { Transaction } from "@metamask/keyring-api"; +import { TronKeyringAccount } from "../../entities"; +import { ILogger } from "../../utils/logger"; +import { State, UnencryptedStateValue } from "../state/State"; + +export class TransactionsService { + readonly #logger: ILogger; + + readonly #loggerPrefix = '[💸 TransactionsService]'; + + readonly #state: State; + + constructor({ logger, state }: { logger: ILogger, state: State }) { + this.#logger = logger; + this.#state = state; + } + + async listTransactions(account: TronKeyringAccount): Promise { + return []; // TODO: Implement me + } +} \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts new file mode 100644 index 00000000..d284da22 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts @@ -0,0 +1,25 @@ +import { ILogger } from "../../utils/logger"; +import { State, UnencryptedStateValue } from "../state/State"; + +export class WalletService { + readonly #logger: ILogger; + + readonly #loggerPrefix = '[👛 WalletService]'; + + readonly #state: State; + + constructor({ logger, state }: { logger: ILogger, state: State }) { + this.#logger = logger; + this.#state = state; + } + + async signMessage(message: string): Promise { + this.#logger.log(this.#loggerPrefix, 'Signing message...', message); + return '0x1234567890'; // TODO: Implement me + } + + async verifyMessage(message: string, signature: string): Promise { + this.#logger.log(this.#loggerPrefix, 'Verifying message...', message, signature); + return true; // TODO: Implement me + } +} \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/src/types/snap.ts b/merged-packages/tron-wallet-snap/src/types/snap.ts new file mode 100644 index 00000000..4dbd7bfa --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/types/snap.ts @@ -0,0 +1,22 @@ +import type { Locale } from '../utils/i18n'; + +export type Preferences = { + locale: Locale; + currency: string; + hideBalances: boolean; + useSecurityAlerts: boolean; + useExternalPricingData: boolean; + simulateOnChainActions: boolean; + useTokenDetection: boolean; + batchCheckBalances: boolean; + displayNftMedia: boolean; + useNftDetection: boolean; +}; + +/** + * 'initial' - The initial state, where no data has been fetched yet. + * 'fetching' - The state where data is being fetched. + * 'fetched' - The state where data has been fetched successfully. + * 'error' - The state where an error occurred while fetching data. + */ +export type FetchStatus = 'initial' | 'fetching' | 'fetched' | 'error'; diff --git a/merged-packages/tron-wallet-snap/src/utils/buildUrl.test.ts b/merged-packages/tron-wallet-snap/src/utils/buildUrl.test.ts new file mode 100644 index 00000000..2d1c4974 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/buildUrl.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-script-url */ +import { buildUrl } from './buildUrl'; + +describe('buildUrl', () => { + it('combines base URL and path correctly', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + }); + expect(result).toBe('https://api.example.com/users'); + }); + + it('adds single query parameter', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users?id=123'); + }); + + it('adds multiple query parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { id: '123', name: 'john', role: 'admin' }, + }); + expect(result).toBe( + 'https://api.example.com/users?id=123&name=john&role=admin', + ); + }); + + it('handles path parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users/{id}', + pathParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users/123'); + }); + + it('handles trailing slash in base URL', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com/', + path: '/users', + queryParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users?id=123'); + }); + + it('throws error for invalid base URL', () => { + expect(() => + buildUrl({ + baseUrl: 'invalid-url', + path: '/users', + queryParams: {}, + }), + ).toThrow('Invalid URL format'); + }); + + // Security validation tests + it('prevents XSS in query parameters', () => { + expect(() => + buildUrl({ + baseUrl: 'https://api.example.com', + path: '/search', + queryParams: { + q: '', + callback: 'javascript:alert(1)', + }, + }), + ).toThrow('URL contains potentially malicious patterns'); + }); + + it('prevents path traversal attacks', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/../../../etc/passwd', + queryParams: {}, + }); + expect(result).toBe('https://api.example.com/etc/passwd'); + }); + + it('handles null and undefined query parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { + id: null as unknown as string, + name: undefined as unknown as string, + valid: 'data', + }, + }); + expect(result).toBe('https://api.example.com/users?valid=data'); + }); + + it('prevents protocol switching in parameters', () => { + expect(() => + buildUrl({ + baseUrl: 'https://api.example.com', + path: '/redirect', + queryParams: { + url: 'javascript://alert(1)', + next: 'data:text/html,', + }, + }), + ).toThrow('URL contains potentially malicious patterns'); + }); + + it('handles empty path segments', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '//path//to//resource//', + queryParams: {}, + }); + expect(result).toBe('https://api.example.com/path/to/resource'); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts b/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts new file mode 100644 index 00000000..ce730a31 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts @@ -0,0 +1,53 @@ +import { assert } from '@metamask/superstruct'; + +import { UrlStruct } from '../validation/structs'; + +export type BuildUrlParams = { + baseUrl: string; + path: string; + pathParams?: Record | undefined; + queryParams?: Record | undefined; +}; + +/** + * Builds a URL with the given base URL and parameters: + * - The `URL` API provides proper URL parsing and encoding. + * - The `path` is sanitized to prevent path traversal attacks. + * + * Ensures that the built URL is safe, valid, and sanitized. + * + * @param params - The parameters to build the URL from. + * @returns The built URL. + */ +export function buildUrl(params: BuildUrlParams): string { + const { baseUrl, path, pathParams, queryParams } = params; + + assert(baseUrl, UrlStruct); + + const pathWithParams = path.replace(/\{(\w+)\}/gu, (_, key: string) => { + const value = pathParams?.[key]; + if (value === undefined) { + throw new Error(`Path parameter ${key} is undefined`); + } + return value; + }); + + const cleanPath = pathWithParams + .replace(/^\/+/u, '') // Remove leading slashes + .replace(/\/+/gu, '/') // Replace multiple slashes with single + .replace(/\/+$/u, ''); // Remove trailing slashes + + const url = new URL(cleanPath, baseUrl); + Object.entries(queryParams ?? {}) + .filter(([_, value]) => value !== undefined) + .filter(([_, value]) => value !== null) + .forEach(([key, value]) => { + if (value) { + url.searchParams.append(key, value); + } + }); + + const builtUrl = url.toString(); + assert(builtUrl, UrlStruct); + return builtUrl; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts b/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts new file mode 100644 index 00000000..ebd6421e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts @@ -0,0 +1,70 @@ +import type { EntropySourceId } from '@metamask/keyring-api'; +import { assert, pattern, string } from '@metamask/superstruct'; +import { hexToBytes } from '@metamask/utils'; + +import { getBip32Entropy } from './getBip32Entropy'; +import logger from './logger'; + +/** + * Validates a Tron derivation path following the format: m/44'/195'/... + */ +const DERIVATION_PATH_REGEX = /^m\/44'\/195'/u; +export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); + +/** + * Elliptic curve + * + * See: https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ed25519/ + */ +const CURVE = 'secp256k1' as const; + +/** + * Derives a Tron private and public key from a given index using BIP44 derivation path. + * The derivation path follows Phantom wallet's standard: m/44'/501'/index'/0'. + * + * @param params - The parameters for the Tron key derivation. + * @param params.entropySource - The entropy source to use for key derivation. + * @param params.derivationPath - The derivation path to use for key derivation. + * @returns A Promise that resolves to a Uint8Array of the private key. + * @throws {Error} If unable to derive private key or if derivation fails. + * @example + * ```typescript + * const { privateKeyBytes, publicKeyBytes } = await deriveSolanaPrivateKey(0); + * ``` + * @see {@link https://help.phantom.app/hc/en-us/articles/12988493966227-What-derivation-paths-does-Phantom-wallet-support} Phantom wallet derivation paths + * @see {@link https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki} BIP44 specification + * @see {@link https://github.com/satoshilabs/slips/blob/master/slip-0044.md} SLIP-0044 for coin types. + */ +export async function deriveTronKeypair({ + entropySource, + derivationPath, +}: { + entropySource?: EntropySourceId | undefined; + derivationPath: string; +}): Promise<{ privateKeyBytes: Uint8Array; publicKeyBytes: Uint8Array }> { + logger.log({ derivationPath }, 'Generating solana wallet'); + + assert(derivationPath, DerivationPathStruct); + + const path = derivationPath.split('/'); + + try { + const node = await getBip32Entropy({ + entropySource, + path, + curve: CURVE, + }); + + if (!node.privateKey || !node.publicKey) { + throw new Error('Unable to derive private key'); + } + + return { + privateKeyBytes: hexToBytes(node.privateKey), + publicKeyBytes: hexToBytes(node.publicKey), + }; + } catch (error: any) { + logger.error({ error }, 'Error deriving keypair'); + throw new Error(error); + } +} diff --git a/merged-packages/tron-wallet-snap/src/utils/errors.test.ts b/merged-packages/tron-wallet-snap/src/utils/errors.test.ts new file mode 100644 index 00000000..561e7ea2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/errors.test.ts @@ -0,0 +1,178 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { SnapError } from '@metamask/snaps-sdk'; + +import { withCatchAndThrowSnapError } from './errors'; +import logger from './logger'; + +// Mock the logger to avoid actual console output during tests +jest.mock('./logger', () => ({ + error: jest.fn(), +})); + +describe('errors', () => { + const mockLogger = logger as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('handle', () => { + it('returns the result when the function succeeds', async () => { + const mockFn = jest.fn().mockResolvedValue('success'); + + const result = await withCatchAndThrowSnapError(mockFn); + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('handles and re-throws errors as SnapError', async () => { + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('logs errors with the correct scope and error details', async () => { + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + try { + await withCatchAndThrowSnapError(mockFn); + } catch (error) { + // Expected to throw + } + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: expect.any(SnapError) }, + expect.stringContaining(`[SnapError]`), + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + const logCall = mockLogger.error.mock.calls[0]; + const loggedError = logCall?.[0]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + }); + + it('handles non-Error objects and converts them to SnapError', async () => { + const nonErrorValue = 'string error'; + const mockFn = jest.fn().mockRejectedValue(nonErrorValue); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + const logCall = mockLogger.error.mock.calls[0]; + const loggedError = logCall?.[0]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + }); + + it('handles null and undefined errors', async () => { + const mockFn = jest.fn().mockRejectedValue(null); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('preserves the original error message in the SnapError', async () => { + const originalError = new Error('Custom error message'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + let caughtError: unknown; + try { + await withCatchAndThrowSnapError(mockFn); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(SnapError); + const snapError = caughtError as SnapError; + expect(snapError.message).toBe('Custom error message'); + }); + + it('handles async functions that return different types', async () => { + const testCases = [ + { value: 42, type: 'number' }, + { value: { key: 'value' }, type: 'object' }, + { value: [1, 2, 3], type: 'array' }, + { value: true, type: 'boolean' }, + { value: null, type: 'null' }, + ]; + + for (const testCase of testCases) { + const mockFn = jest.fn().mockResolvedValue(testCase.value); + + const result = await withCatchAndThrowSnapError(mockFn); + + expect(result).toBe(testCase.value); + expect(mockLogger.error).not.toHaveBeenCalled(); + } + }); + + it('handles functions that throw different error types', async () => { + const errorTypes = [ + new TypeError('Type error'), + new ReferenceError('Reference error'), + new RangeError('Range error'), + new SyntaxError('Syntax error'), + ]; + + for (const errorType of errorTypes) { + const mockFn = jest.fn().mockRejectedValue(errorType); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + } + + expect(mockLogger.error).toHaveBeenCalledTimes(errorTypes.length); + const logCalls = mockLogger.error.mock.calls; + expect(logCalls).toHaveLength(errorTypes.length); + + for (let i = 0; i < errorTypes.length; i++) { + const logCall = logCalls[i]; + const loggedError = logCall?.[0]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + expect(loggedError?.message).toBe(errorTypes[i]?.message); + } + }); + + it('includes error stack trace in the logged error', async () => { + const originalError = new Error('Test error'); + originalError.stack = 'Error: Test error\n at test.js:1:1'; + const mockFn = jest.fn().mockRejectedValue(originalError); + + try { + await withCatchAndThrowSnapError(mockFn); + } catch (error) { + // Expected to throw + } + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: expect.any(SnapError) }, + expect.stringContaining('[SnapError]'), + ); + }); + + it('handles functions that throw promises', async () => { + const rejectedPromise = Promise.reject(new Error('Promise error')); + const mockFn = jest.fn().mockImplementation(async () => rejectedPromise); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/errors.ts b/merged-packages/tron-wallet-snap/src/utils/errors.ts new file mode 100644 index 00000000..9546328f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/errors.ts @@ -0,0 +1,70 @@ +import { + ChainDisconnectedError, + DisconnectedError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + MethodNotFoundError, + MethodNotSupportedError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + SnapError, + TransactionRejected, + UnauthorizedError, + UnsupportedMethodError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; + +import logger from './logger'; + +/** + * Determines if the given error is a Snap RPC error. + * + * @param error - The error instance to be checked. + * @returns A boolean indicating whether the error is a Snap RPC error. + */ +export function isSnapRpcError(error: Error): boolean { + const errors = [ + SnapError, + MethodNotFoundError, + UserRejectedRequestError, + MethodNotSupportedError, + MethodNotFoundError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + TransactionRejected, + ChainDisconnectedError, + DisconnectedError, + UnauthorizedError, + UnsupportedMethodError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + ]; + return errors.some((errType) => error instanceof errType); +} + +export const withCatchAndThrowSnapError = async ( + fn: () => Promise, +): Promise => { + try { + return await fn(); + } catch (errorInstance: any) { + const error = isSnapRpcError(errorInstance) + ? errorInstance + : new SnapError(errorInstance); + + logger.error( + { error }, + `[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`, + ); + + throw error; + } +}; diff --git a/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.test.ts b/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.test.ts new file mode 100644 index 00000000..33aad4b5 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.test.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-restricted-globals */ +import { getBip32Entropy } from './getBip32Entropy'; + +describe('getBip32Entropy', () => { + beforeEach(() => { + jest.resetAllMocks(); + Object.defineProperty(global, 'snap', { + value: { + request: jest.fn().mockResolvedValueOnce({ + depth: 2, + masterFingerprint: 3974444335, + parentFingerprint: 2046425034, + index: 2147484149, + curve: 'ed25519', + privateKey: + '0x7acf6060833428c2196ce6e2c5ba5455394602814b9ec6b9bac453b357be7b24', + publicKey: + '0x00389ed03449fbc42a3ec134609b664a50e7a78bad800bad1629113590bfc9af9b', + chainCode: + '0x99d7cef35ae591a92eab31e0007f0199e3bea62d211a219526bf2ae06799886d', + }), + }, + writable: true, + }); + }); + + describe('when we have a valid path and we get a response from the Extension', () => { + it('returns the correct entropy', async () => { + const path = ['m', "44'", "501'"]; + const curve = 'ed25519'; + + await getBip32Entropy({ path, curve }); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + }, + }); + }); + }); + + describe('when we have a valid entropy source and path and we get a response from the Extension', () => { + it('returns the correct entropy', async () => { + const entropySource = '01JR0PT6PNGBN7MRM3MPEVQPC0'; + const path = ['m', "44'", "501'"]; + const curve = 'ed25519'; + + await getBip32Entropy({ entropySource, path, curve }); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getBip32Entropy', + params: { source: entropySource, path, curve }, + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts b/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts new file mode 100644 index 00000000..64dc0a1f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts @@ -0,0 +1,32 @@ +import type { JsonSLIP10Node } from '@metamask/key-tree'; +import type { EntropySourceId } from '@metamask/keyring-api'; + +/** + * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. + * + * @param params - The parameters for the Solana key derivation. + * @param params.entropySource - The entropy source to use for key derivation. + * @param params.path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. + * @param params.curve - The elliptic curve to use for key derivation. + * @returns A Promise that resolves to a `SLIP10NodeInterface` object. + */ +export async function getBip32Entropy({ + entropySource, + path, + curve, +}: { + entropySource?: EntropySourceId | undefined; + path: string[]; + curve: 'secp256k1' | 'ed25519'; +}): Promise { + const node = await snap.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + ...(entropySource ? { source: entropySource } : {}), + }, + }); + + return node; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.test.ts b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.test.ts new file mode 100644 index 00000000..2ceafcee --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.test.ts @@ -0,0 +1,60 @@ +import { + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_2, + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + MOCK_SOLANA_KEYRING_ACCOUNT_5, +} from '../test/mocks/solana-keyring-accounts'; +import { getLowestUnusedIndex } from './getLowestUnusedIndex'; + +describe('getLowestUnusedIndex', () => { + it('returns 0 when no accounts exist', () => { + expect(getLowestUnusedIndex([])).toBe(0); + }); + + it('returns 1 when only index 0 is used', () => { + const accounts = [MOCK_SOLANA_KEYRING_ACCOUNT_0]; + expect(getLowestUnusedIndex(accounts)).toBe(1); + }); + + it('finds gap in sequential indices', () => { + // Gap at index 2 + const accounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + ]; + expect(getLowestUnusedIndex(accounts)).toBe(2); + }); + + it('handles unordered indices', () => { + const accounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_3, + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + ]; + expect(getLowestUnusedIndex(accounts)).toBe(2); + }); + + it('returns next number after continuous sequence', () => { + const accounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_2, + ]; + expect(getLowestUnusedIndex(accounts)).toBe(3); + }); + + it('finds first gap with sparse indices', () => { + const accounts = [ + MOCK_SOLANA_KEYRING_ACCOUNT_0, + MOCK_SOLANA_KEYRING_ACCOUNT_1, + MOCK_SOLANA_KEYRING_ACCOUNT_4, + MOCK_SOLANA_KEYRING_ACCOUNT_5, + ]; + expect(getLowestUnusedIndex(accounts)).toBe(2); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts new file mode 100644 index 00000000..bcf5126e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts @@ -0,0 +1,42 @@ +export type WithIndex = { + index: number; +}; + +/** + * Generating a new index for the KeyringAccount is not as straightforward as one might think. + * We cannot assume that this number will continuosly increase because one can delete an account with + * an index in the middle of the list. The right way to do it is to loop through the keyringAccounts + * and get the lowest index that is not yet used. + * + * This function does precisely that, in a generic way, as it can work with any array of items that + * have a field `index`. + * + * Eg: + * Used Indices: [] -> Lowest is 0. + * Used Indices: [0, 1, 2, 4] -> Lowest is 3. + * + * @param items - The items to check. + * @returns The lowest unused index. + */ +export function getLowestUnusedIndex(items: WithIndex[]): number { + if (items.length === 0) { + return 0; + } + + const usedIndices = items.map((item) => item.index).sort((a, b) => a - b); + + let lowestUnusedIndex = 0; + + for (const usedIndex of usedIndices) { + /** + * From lower to higher, the moment we find a gap, we can use it + */ + if (usedIndex !== lowestUnusedIndex) { + break; + } + + lowestUnusedIndex += 1; + } + + return lowestUnusedIndex; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/i18n.test.ts b/merged-packages/tron-wallet-snap/src/utils/i18n.test.ts new file mode 100644 index 00000000..05f5c26f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/i18n.test.ts @@ -0,0 +1,30 @@ +import { i18n, locales } from './i18n'; + +describe('i18n', () => { + it('returns the correct translation for a given key', () => { + const translate = i18n('en'); + const message = translate('send.balance'); + expect(message).toBe(locales.en['send.balance'].message); + }); + + it('returns the correct translation for a given key and replaces', () => { + const translate = i18n('en'); + const message = translate('send.transaction-failure.subtitle', { + amount: '1.23', + tokenSymbol: 'SOL', + }); + expect(message).toBe('Unable to send 1.23 SOL'); + }); + + it('falls back to the default language if the preferred locale is not available', () => { + const translate = i18n('fr' as any); + const message = translate('send.balance'); + expect(message).toBe(locales.en['send.balance'].message); + }); + + it('returns the key for a non-existent key', () => { + const translate = i18n('en'); + const message = translate('nonExistentKey' as any); + expect(message).toBe('nonExistentKey'); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/i18n.ts b/merged-packages/tron-wallet-snap/src/utils/i18n.ts new file mode 100644 index 00000000..ff7b61e2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/i18n.ts @@ -0,0 +1,37 @@ +import en from '../../locales/en.json'; + +export const locales = { + en: en.messages, +}; + +export const FALLBACK_LANGUAGE: Locale = 'en'; + +export type Locale = keyof typeof locales; +export type LocalizedMessage = keyof (typeof locales)[typeof FALLBACK_LANGUAGE]; + +/** + * Fetches the translations based on the user's locale preference. + * Falls back to the default language if the preferred locale is not available. + * + * @param locale - The user's preferred locale. + * @returns A function that gets the translation for a given key. + */ +export function i18n(locale: Locale) { + // Needs to be castes as EN is the main language and we can have the case where + // messages are not yed completed for the other languages + + const messages = locales[locale] ?? locales[FALLBACK_LANGUAGE]; + + return (id: LocalizedMessage, replaces?: Record): string => { + let message = messages?.[id]?.message ?? id; + + if (replaces && message) { + Object.keys(replaces).forEach((key) => { + const regex = new RegExp(`\\{${key}\\}`, 'gu'); + message = message.replace(regex, replaces[key] ?? ''); + }); + } + + return message; + }; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/interface.ts b/merged-packages/tron-wallet-snap/src/utils/interface.ts new file mode 100644 index 00000000..e457ca92 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/interface.ts @@ -0,0 +1,125 @@ +import type { + DialogResult, + EntropySource, + GetClientStatusResult, + GetInterfaceStateResult, + Json, + ResolveInterfaceResult, +} from '@metamask/snaps-sdk'; + +import type { Preferences } from '../types/snap'; + +/** + * Gets the state of an interactive interface by its ID. + * + * @param id - The ID for the interface to update. + * @returns An object containing the state of the interface. + */ +export async function getInterfaceState( + id: string, +): Promise { + return snap.request({ + method: 'snap_getInterfaceState', + params: { + id, + }, + }); +} + +/** + * Resolve a dialog using the provided ID. + * + * @param id - The ID for the interface to update. + * @param value - The result to resolve the interface with. + * @returns An object containing the state of the interface. + */ +export async function resolveInterface( + id: string, + value: Json, +): Promise { + return snap.request({ + method: 'snap_resolveInterface', + params: { + id, + value, + }, + }); +} + +/** + * Shows a dialog using the provided ID. + * + * @param id - The ID for the dialog. + * @returns A promise that resolves to a string. + */ +export async function showDialog(id: string): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + id, + }, + }); +} + +/** + * Get preferences from snap. + * + * @returns A promise that resolves to snap preferences. + */ +export async function getPreferences(): Promise { + return snap.request({ + method: 'snap_getPreferences', + }) as Promise; +} + +/** + * Retrieves the client status (locked/unlocked) in this case from MM. + * + * @returns An object containing the status. + */ +export async function getClientStatus(): Promise { + return await snap.request({ + method: 'snap_getClientStatus', + }); +} + +/** + * Schedules a background event. + * + * @param options - The options for the background event. + * @param options.method - The method to call. + * @param options.params - The params to pass to the method. + * @param options.duration - The duration to wait before the event is scheduled. + * @returns A promise that resolves to a string. + */ +export async function scheduleBackgroundEvent({ + method, + params = {}, + duration, +}: { + method: string; + params?: Record; + duration: string; +}): Promise { + return await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration, + request: { + method, + params, + }, + }, + }); +} + +/** + * List all entropy sources. + * + * @returns An array of entropy sources. + */ +export async function listEntropySources(): Promise { + return await snap.request({ + method: 'snap_listEntropySources', + }); +} diff --git a/merged-packages/tron-wallet-snap/src/utils/logger.ts b/merged-packages/tron-wallet-snap/src/utils/logger.ts new file mode 100644 index 00000000..03886b5a --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/logger.ts @@ -0,0 +1,50 @@ +/** + * A simple logger utility that provides methods for logging messages at different levels. + * For now, it's just a wrapper around console. + * + * @namespace logger + */ + +export type ILogger = { + log: (...args: any[]) => void; + info: (...args: any[]) => void; + warn: (...args: any[]) => void; + error: (...args: any[]) => void; + debug: (...args: any[]) => void; +}; + +const withSolanaErrorLogging = + (logFn: (...args: any[]) => void) => + (...args: any[]): void => { + logFn(...args); + }; + +/** + * A decorator function that noops if the environment is not local, + * and runs the decorated function otherwise. + * + * @param fn - The function to wrap. + * @returns The wrapped function. + */ +const withNoopInProduction = + (fn: (...args: any[]) => void) => + (...args: any[]): void => { + // eslint-disable-next-line no-restricted-globals + if (process.env.ENVIRONMENT === 'production') { + return; + } + fn(...args); + }; + +/** + * A basic logger that wraps the console, extending its functionality to properly log Solana errors. + */ +const logger: ILogger = { + log: withNoopInProduction(console.log), + info: withNoopInProduction(console.info), + warn: withNoopInProduction(console.warn), + debug: withNoopInProduction(console.debug), + error: withNoopInProduction(withSolanaErrorLogging(console.error)), +}; + +export default logger; diff --git a/merged-packages/tron-wallet-snap/src/utils/safeMerge.test.ts b/merged-packages/tron-wallet-snap/src/utils/safeMerge.test.ts new file mode 100644 index 00000000..93666ab1 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/safeMerge.test.ts @@ -0,0 +1,119 @@ +import { safeMerge } from './safeMerge'; + +describe('safeMerge', () => { + it('merges two objects and keeps existing values when overrider has undefined', () => { + const overridee = { name: 'John', age: 25 }; + const overrider = { name: undefined, title: 'Developer' }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + name: 'John', + age: 25, + title: 'Developer', + }); + }); + + it('overrides values when overrider has non-undefined values', () => { + const overridee = { name: 'John', age: 25 }; + const overrider = { name: 'Jane', title: 'Engineer' }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + name: 'Jane', + age: 25, + title: 'Engineer', + }); + }); + + it('handles empty objects', () => { + const overridee = {}; + const overrider = {}; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({}); + }); + + it('handles objects with null values', () => { + const overridee = { name: 'John', age: null }; + const overrider = { name: null, title: 'Developer' }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + name: 'John', + age: null, + title: 'Developer', + }); + }); + + it('handles nested objects', () => { + const overridee = { + user: { + name: 'John', + details: { age: 25 }, + }, + }; + const overrider = { + user: { + name: undefined, + details: { location: 'NYC' }, + }, + }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + user: { + name: undefined, + details: { location: 'NYC' }, + }, + }); + }); + + it('filters out empty objects in overrider', () => { + const overridee = { name: 'John', settings: { theme: 'dark' } }; + const overrider = { name: 'Jane', settings: {} }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + name: 'Jane', + settings: { theme: 'dark' }, + }); + }); + + it('keeps non-empty objects in overrider', () => { + const overridee = { settings: { theme: 'dark' } }; + const overrider = { settings: { language: 'en' } }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + settings: { language: 'en' }, + }); + }); + + it('merges multiple empty and non-empty nested objects', () => { + const overridee = { + a: { x: 1 }, + b: { y: 2 }, + c: { z: 3 }, + }; + const overrider = { + a: {}, + b: { y: 5 }, + c: {}, + }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + a: { x: 1 }, + b: { y: 5 }, + c: { z: 3 }, + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts b/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts new file mode 100644 index 00000000..69a12472 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts @@ -0,0 +1,28 @@ +/** + * Merges two objects, keeping values from the overridee object when the overrider's corresponding properties are undefined, + * null, or empty objects. Non-undefined values from the overrider take precedence. Empty objects in the overrider are + * filtered out to preserve the overridee's values. + * + * @param overridee - The object to override. + * @param overrider - The object to override with. + * @returns The merged object. + * @example + * const overridee = { name: 'John' }; + * const overrider = { name: undefined, age: 30 }; + * const merged = safeMerge(overridee, overrider); + * // merged is { name: 'John' } + */ +export const safeMerge = ( + overridee: TOverridee, + overrider: TOverrider, +): TOverridee & TOverrider => ({ + ...overridee, + ...(Object.fromEntries( + Object.entries(overrider).filter( + ([_, value]) => + value !== undefined && + value !== null && + (!value || typeof value !== 'object' || Object.keys(value).length > 0), + ), + ) as TOverrider), +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts new file mode 100644 index 00000000..4d8843d5 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts @@ -0,0 +1,95 @@ +/* eslint-disable jest/prefer-strict-equal */ +/* eslint-disable @typescript-eslint/naming-convention */ +import BigNumber from 'bignumber.js'; + +import { deserialize } from './deserialize'; + +describe('deserialize', () => { + it('deserializes primitive values', () => { + expect(deserialize('test')).toBe('test'); + expect(deserialize(42)).toBe(42); + expect(deserialize(true)).toBe(true); + expect(deserialize(null)).toBeNull(); + }); + + it('deserializes special serialized types', () => { + expect(deserialize({ __type: 'undefined' })).toBeUndefined(); + expect( + deserialize({ __type: 'bigint', value: '9007199254740991' }), + ).toStrictEqual(BigInt(9007199254740991)); + expect( + deserialize({ __type: 'BigNumber', value: '123456789.123456789' }), + ).toStrictEqual(new BigNumber('123456789.123456789')); + }); + + it('deserializes arrays with mixed types', () => { + expect( + deserialize([ + 1, + 'hello', + true, + null, + { __type: 'undefined' }, + { __type: 'bigint', value: '9007199254740991' }, + { __type: 'BigNumber', value: '123456789.123456789' }, + ]), + ).toEqual([ + 1, + 'hello', + true, + null, + undefined, + BigInt(9007199254740991), + new BigNumber('123456789.123456789'), + ]); + }); + + it('deserializes objects with nested structures', () => { + const input = { + nested: { + bigNumber: { __type: 'BigNumber', value: '123.456' }, + bigint: { __type: 'bigint', value: '9007199254740991' }, + undefined: { __type: 'undefined' }, + }, + array: [ + { __type: 'BigNumber', value: '789.012' }, + { __type: 'bigint', value: '9007199254740992' }, + ], + }; + + const result = deserialize(input); + + expect(result).toEqual({ + nested: { + bigNumber: new BigNumber('123.456'), + bigint: BigInt('9007199254740991'), + undefined, + }, + array: [new BigNumber('789.012'), BigInt('9007199254740992')], + }); + }); + + it('handles non-undefined falsy values correctly', () => { + const input = { + zero: 0, + emptyString: '', + falseValue: false, + nullValue: null, + }; + + const result = deserialize(input); + + expect(result).toStrictEqual({ + zero: 0, + emptyString: '', + falseValue: false, + nullValue: null, + }); + }); + + it('deserializes Uint8Array', () => { + const input = { __type: 'Uint8Array', value: 'AQID' }; + const result = deserialize(input); + expect(result).toStrictEqual(new Uint8Array([1, 2, 3])); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts new file mode 100644 index 00000000..6d7d3b6c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts @@ -0,0 +1,36 @@ +import type { Json } from '@metamask/snaps-sdk'; +import BigNumber from 'bignumber.js'; + +import type { Serializable } from './types'; + +/** + * Deserializes the passed value from a JSON object to an object with its the original values. + * It transforms the JSON-serializable representation of non-JSON-serializable values back into their original values. + * + * @param serializedValue - The value to deserialize. + * @returns The deserialized value. + */ +export const deserialize = (serializedValue: Json): Serializable => + JSON.parse(JSON.stringify(serializedValue), (_key, value) => { + if (!value) { + return value; + } + + if (value.__type === 'undefined') { + return undefined; + } + + if (value.__type === 'BigNumber') { + return new BigNumber(value.value); + } + + if (value.__type === 'bigint') { + return BigInt(value.value); + } + + if (value.__type === 'Uint8Array') { + return Buffer.from(value.value, 'base64'); // Uses 'base64' encoding + } + + return value; + }); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts new file mode 100644 index 00000000..d6a2103d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts @@ -0,0 +1,107 @@ +/* eslint-disable jest/prefer-strict-equal */ +/* eslint-disable @typescript-eslint/naming-convention */ +import BigNumber from 'bignumber.js'; + +import { serialize } from './serialize'; + +describe('serialize', () => { + it('serializes primitive values', () => { + expect(serialize('test')).toBe('test'); + expect(serialize(42)).toBe(42); + expect(serialize(true)).toBe(true); + expect(serialize(null)).toBeNull(); + expect(serialize(undefined)).toStrictEqual({ __type: 'undefined' }); + }); + + it('serializes special types', () => { + expect(serialize(BigInt(9007199254740991))).toStrictEqual({ + __type: 'bigint', + value: '9007199254740991', + }); + expect(serialize(new BigNumber('123456789.123456789'))).toStrictEqual({ + __type: 'BigNumber', + value: '123456789.123456789', + }); + }); + + it('serializes arrays with mixed types', () => { + const input = [undefined, new BigNumber('123'), BigInt('456')]; + const result = serialize(input); + expect(result).toStrictEqual([ + { __type: 'undefined' }, + { __type: 'BigNumber', value: '123' }, + { __type: 'bigint', value: '456' }, + ]); + }); + + it('serializes objects with nested structures', () => { + const input = { + nested: { + bigNumber: new BigNumber('123.456'), + bigint: BigInt('9007199254740991'), + undefined, + }, + array: [new BigNumber('789.012'), BigInt('9007199254740992')], + }; + + const result = serialize(input); + + expect(result).toStrictEqual({ + nested: { + bigNumber: { __type: 'BigNumber', value: '123.456' }, + bigint: { __type: 'bigint', value: '9007199254740991' }, + undefined: { __type: 'undefined' }, + }, + array: [ + { __type: 'BigNumber', value: '789.012' }, + { __type: 'bigint', value: '9007199254740992' }, + ], + }); + }); + + it('serializes empty objects and arrays', () => { + const input = { + emptyObject: {}, + emptyArray: [], + }; + + const result = serialize(input); + + expect(result).toStrictEqual(input); + }); + + it('serializes deeply nested structures', () => { + const input = { + level1: { + level2: { + level3: { + bigint: BigInt('123'), + undefined, + }, + }, + }, + }; + + const result = serialize(input); + + expect(result).toStrictEqual({ + level1: { + level2: { + level3: { + bigint: { __type: 'bigint', value: '123' }, + undefined: { __type: 'undefined' }, + }, + }, + }, + }); + }); + + it('serializes Uint8Array', () => { + const input = new Uint8Array([1, 2, 3]); + const result = serialize(input); + expect(result).toStrictEqual({ + __type: 'Uint8Array', + value: 'AQID', + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts new file mode 100644 index 00000000..cd03a46b --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Json } from '@metamask/snaps-sdk'; +import BigNumber from 'bignumber.js'; +import { cloneDeepWith } from 'lodash'; + +import type { Serializable } from './types'; + +/** + * Serializes the passed value to a JSON object so it can be stored in JSON-serializable storage like the snap state and interface context. + * It transforms non-JSON-serializable values into a specific JSON-serializable representation that can be deserialized later. + * + * @param value - The value to serialize. + * @returns The serialized value. + * @throws If an unsupported case is encountered. This indicates a missing implementation. + */ +export const serialize = (value: Serializable): Record => + cloneDeepWith(value, (val: unknown) => { + if (val === undefined) { + return { + __type: 'undefined', + }; + } + + if (val instanceof BigNumber) { + return { + __type: 'BigNumber', + value: val.toString(), + }; + } + + if (typeof val === 'bigint') { + return { + __type: 'bigint', + value: val.toString(), + }; + } + + if (val instanceof Uint8Array) { + return { + __type: 'Uint8Array', + value: Buffer.from(val).toString('base64'), + }; + } + + // Return undefined to let lodash handle the cloning of other values + return undefined; + }); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts new file mode 100644 index 00000000..ad86620e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts @@ -0,0 +1,17 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type BigNumber from 'bignumber.js'; + +/** + * A primitive value that can be serialized to JSON using the `serialize` function. + */ +export type Serializable = + | Json + | undefined + | null + | bigint + | BigNumber + | Uint8Array + | Serializable[] + | { + [prop: string]: Serializable; + }; diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.test.ts b/merged-packages/tron-wallet-snap/src/validation/structs.test.ts new file mode 100644 index 00000000..ad1eed6d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/structs.test.ts @@ -0,0 +1,243 @@ +/* eslint-disable jest/require-to-throw-message */ +import { assert, is } from '@metamask/superstruct'; + +import { Base58Struct, Base64Struct, UrlStruct, UuidStruct } from './structs'; + +describe('structs', () => { + describe('Uuid', () => { + it('validates valid UUIDs', () => { + const validUuids = [ + 'c747acb9-1b2b-4352-b9da-3d658fcc3cc7', + '2507a426-ac26-43c4-a82a-250f5d999398', + '52d181f4-d050-4971-b448-17c15107fa3b', + ]; + + validUuids.forEach((uuid) => { + expect(() => assert(uuid, UuidStruct)).not.toThrow(); + expect(is(uuid, UuidStruct)).toBe(true); + }); + }); + }); + + describe('UrlStruct', () => { + it('validates valid URLs', () => { + const validUrls = [ + 'http://example.com', + 'https://example.com', + 'https://www.example.com', + 'https://sub.example.com', + 'https://example.com/path', + 'https://example.com/path?query=123', + 'https://example.com/path?query=123&other=456', + 'https://example.com:8080', + 'https://example.com/path-with-hyphens', + 'https://example.com/path_with_underscore', + 'http://localhost:8899', + 'wss://example.com', + ]; + + validUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).not.toThrow(); + expect(is(url, UrlStruct)).toBe(true); + }); + }); + + it('rejects invalid URLs', () => { + const invalidUrls = [ + '', + 'not-a-url', + 'ftp://example.com', + 'example.com', + 'http:/example.com', + 'http://example', + 'http:///example.com', + 'http:// example.com', + // eslint-disable-next-line no-script-url + 'javascript:alert(1)', + 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', + ]; + + invalidUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).toThrow(); + expect(is(url, UrlStruct)).toBe(false); + }); + }); + + it('rejects malicious URLs', () => { + const maliciousUrls = [ + // XSS Attacks + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?
', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + + // Additional XSS Variants + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?
', + + // SQL Injection Attempts + 'https://example.com?id=1%27%20OR%20%271%27=%271', + 'https://example.com?id=1%20UNION%20SELECT%20*%20FROM%20users', + + // Directory Traversal + 'https://example.com/../../../etc/passwd', + 'https://example.com/..%2f..%2f..%2fetc%2fpasswd', + + // Command Injection + 'https://example.com?cmd=|ls', + 'https://example.com?cmd=;cat%20/etc/passwd', + + // Protocol Pollution + 'https://example.com\\@evil.com', + 'https://example.com%2f@evil.com', + 'https://example.com?url=javascript:alert(1)', + + // Unicode/UTF-8 Attacks + 'https://example.com/%2e%2e/%2e%2e/%2e%2e/etc/passwd', + 'https://example.com/⒕⒖⒗', + + // CRLF Injection + 'https://example.com?%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK', + + // Open Redirect + 'https://example.com?redirect=//evil.com', + 'https://example.com?url=https://evil.com', + + // HTML Injection without script tags + 'https://example.com?param=test', + 'https://example.com?param=', + + // Data URI schemes + 'data:text/html,', + 'data:application/x-javascript,alert(1)', + + // Null Byte Attacks + 'https://example.com/file.jpg%00.php', + + // Template Injection + // eslint-disable-next-line no-template-curly-in-string + 'https://example.com?${7*7}', + 'https://example.com?#{7*7}', + ]; + maliciousUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).toThrow(); + expect(is(url, UrlStruct)).toBe(false); + }); + }); + }); + + describe('Base58Struct', () => { + it('validates valid Base58 strings', () => { + const validBase58Strings = [ + '72k1xXWG59wUsYv7h2', // Decoded: "Hello, world!" + '3yZe7d', // Decoded: "Test" + 'JxF12TrwUP45BMd', // Decoded: "Base58 Example" + '5Q444645Hz4hD7AuSj5z8m6jKLd3TxoMwp4Y7UWVKGqy', // Example Solana address + 'Qmf412jQZiuVUtdgnB36FXFX7xg5V6KEbSJ4dpQuhkLyfD', // Example IPFS hash + '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', // All valid Base58 chars + ]; + + validBase58Strings.forEach((base58String) => { + expect(() => assert(base58String, Base58Struct)).not.toThrow(); + expect(is(base58String, Base58Struct)).toBe(true); + }); + }); + + it('rejects invalid Base58 strings', () => { + const invalidBase58Strings = [ + 'invalid-base58-string', + '', // empty string + '0', // 0 is not used in Base58 + 'O', // uppercase O is not used in Base58 + 'l', // lowercase L is not used in Base58 + 'I', // uppercase i is not used in Base58 + 'hello world', // spaces not allowed + 'base+58', // special characters not allowed + '12345!', // exclamation mark not allowed + 'ABC 123', // spaces not allowed + 'TEST@123', // @ symbol not allowed + '你好', // non-ASCII characters + 'BASE_58', // underscore not allowed + 'test\n123', // newlines not allowed + 'test\t123', // tabs not allowed + ]; + + invalidBase58Strings.forEach((base58String) => { + expect(() => assert(base58String, Base58Struct)).toThrow(); + expect(is(base58String, Base58Struct)).toBe(false); + }); + }); + }); + + describe('Base64Struct', () => { + it('validates valid Base64 strings', () => { + const validBase64Strings = [ + 'SGVsbG8sIFdvcmxkIQ==', // "Hello, World!" + 'dGVzdA==', // "test" + 'YWJzb2x1dGU=', // "aBc" + 'aGVsbG8=', // "hello" + 'aGVsbG8sIHdvcmxkIQ==', // "hello, world!" + ]; + + validBase64Strings.forEach((base64String) => { + expect(() => assert(base64String, Base64Struct)).not.toThrow(); + expect(is(base64String, Base64Struct)).toBe(true); + }); + }); + + it('rejects invalid Base64 strings', () => { + const invalidBase64Strings = [ + // Invalid characters + 'ABC!DEF', // Contains '!' which is not in Base64 alphabet + 'ABC DEF', // Contains space which is not in Base64 alphabet + 'ABC_DEF', // Contains '_' which is not in standard Base64 (but is in URL-safe variant) + 'ABC-DEF', // Contains '-' which is not in standard Base64 (but is in URL-safe variant) + + // Invalid padding + 'A=', // Single character with padding (should be 'A===') + 'AB=', // Two characters with single padding (should be 'AB==') + 'ABC=A', // Padding in the middle + 'ABCD=', // Padding when not needed (length is multiple of 4) + 'A===', // Too much padding for a single character + 'AB===', // Too much padding for two characters + 'ABC==', // Too much padding for three characters + + // Invalid length + 'A', // Single character without proper padding + 'AB', // Two characters without proper padding + 'ABC', // Three characters without proper padding + + // Padding in wrong position + '=ABC', // Padding at the beginning + 'A=BC', // Padding in the middle + 'AB=C', // Padding in the middle + + // Mixed issues + 'A=B=C=', // Multiple padding characters in wrong positions + 'AB!C=', // Invalid character and padding + 'ABCDE=', // Length not a multiple of 4 with incorrect padding + + // Empty string + '', // Empty string is not valid Base64 + + // URL-safe Base64 characters in standard Base64 + 'ABC-DEF+GHI', // Mixed URL-safe and standard Base64 + 'ABC_DEF/GHI', // Mixed URL-safe and standard Base64 + ]; + + invalidBase64Strings.forEach((base64String) => { + expect(() => assert(base64String, Base64Struct)).toThrow(base64String); + expect(is(base64String, Base64Struct)).toBe(false); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.ts b/merged-packages/tron-wallet-snap/src/validation/structs.ts new file mode 100644 index 00000000..9617a8d4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/structs.ts @@ -0,0 +1,309 @@ +import { CaipAssetTypeStruct, SolMethod } from '@metamask/keyring-api'; +import type { Struct } from '@metamask/superstruct'; +import { + array, + define, + enums, + integer, + nullable, + object, + optional, + pattern, + record, + refine, + string, +} from '@metamask/superstruct'; + +import { Network } from '../constants/tron'; + +// create a uuid validation +export const UuidStruct = pattern( + string(), + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u, +); + +export const PositiveNumberStringStruct = pattern( + string(), + /^(?!0\d)(\d+(\.\d+)?)$/u, +); + +/** + * Validates that a string is a valid and safe URL. + * + * It rejects: + * - Non-HTTP/HTTPS/WSS protocols + * - Malformed URL format or incorrect protocol format + * - Invalid hostname format (must follow proper domain naming conventions) + * - Protocol pollution attempts (backslashes, @ symbol, %2f@, %5c@) + * - Invalid hostname characters (backslashes, @ symbol, forward slashes, encoded forward slashes) + * - Directory traversal attempts (../, ..%2f, ..%2F) + * + * Dangerous patterns including: + * - HTML tags. + * - JavaScript protocol. + * - Data URI scheme. + * - Template injection (${...}, #{...}). + * - Command injection (|, ;). + * - CRLF injection. + * - URL credential injection. + * - SQL injection attempts. + * - Open redirect parameters. + * - Non-printable characters. + */ +export const UrlStruct = refine(string(), 'safe-url', (value) => { + try { + // Basic URL validation + const url = new URL(value); + + // Protocol check + const supportedProtocols = ['http:', 'https:', 'wss:']; + if (!supportedProtocols.includes(url.protocol)) { + return `URL must use one of the following protocols: ${supportedProtocols}`; + } + + // Validate URL format + if (!value.match(/^(https?|wss):\/\/[^/]+\/?/u)) { + return 'Malformed URL - incorrect protocol format'; + } + + // Validate hostname format. Accepts localhost and ports (needed for tests) + const hostname = url.hostname.toLowerCase(); + if ( + hostname !== 'localhost' && + (!hostname.includes('.') || + !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/u.test( + hostname, + )) + ) { + return 'Invalid hostname format'; + } + + // Check for protocol pollution in the entire URL + const decodedValue = decodeURIComponent(value.toLowerCase()); + if ( + value.includes('\\') || + value.includes('@') || + decodedValue.includes('\\') || + decodedValue.includes('@') || + value.toLowerCase().includes('%2f@') || + value.toLowerCase().includes('%5c@') + ) { + return 'URL contains protocol pollution attempts'; + } + + // Additional hostname safety check for protocol pollution + const decodedHostname = decodeURIComponent(hostname); + if ( + hostname.includes('\\') || + hostname.includes('@') || + decodedHostname.includes('/') || + hostname.toLowerCase().includes('%2f') + ) { + return 'Invalid hostname characters detected'; + } + + // Check for directory traversal + if ( + value.includes('../') || + value.includes('..%2f') || + value.includes('..%2F') + ) { + return 'Directory traversal attempts are not allowed'; + } + + // Check for dangerous patterns + const dangerousPatterns = [ + /<[^>]*>/u, // HTML tags + /javascript:/u, // JavaScript protocol + /data:/u, // Data URI scheme + /\\[@\\]/u, // Enhanced protocol pollution check + /%2f@/u, // Protocol pollution + /[^\x20-\x7E]/u, // Non-printable characters + /\$\{.*?\}/u, // Template injection + /#\{.*?\}/u, // Template injection + /[|;]/u, // Command injection + /%0[acd]|%0[acd]/u, // CRLF injection + /\/\/\w+@/u, // URL credential injection + // Enhanced SQL injection patterns + /(?:[^a-z]|^)(?:union\s+(?:all\s+)?select|select\s+(?:.*\s+)?from|insert\s+into|update\s+.*\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|exec(?:ute)?|union|where\s+[\d\w]\s*=\s*[\d\w]|\bor\b\s*[\d\w]\s*=\s*[\d\w])/iu, + /'.*?(?:OR|UNION|SELECT|FROM|WHERE).*?'/iu, // SQL injection + /%27.*?(?:OR|UNION|SELECT|FROM|WHERE).*?(?:%27|')/iu, // URL-encoded SQL injection + /%20(?:OR|UNION|SELECT|FROM|WHERE)%20/iu, // URL-encoded SQL keywords + /[?&](?:url|redirect|next|return_to|return_url|goto|destination|continue|redirect_uri)=(?:[^&]*\/\/|https?:)/iu, // Open redirect parameters + /[?&](?:url|redirect|next|return_to|return_url|goto|destination|continue|redirect_uri)=%(?:[^&]*\/\/|https?:)/iu, // URL-encoded open redirect parameters + ]; + + for (const patt of dangerousPatterns) { + if (patt.test(decodedValue)) { + return 'URL contains potentially malicious patterns'; + } + } + + // Port validation (if present) + if (url.port && !/^\d+$/u.test(url.port)) { + return 'Invalid port number'; + } + + return true; + } catch (error) { + return 'Invalid URL format'; + } +}); + +/** + * Keyring validations + */ +export const GetAccountStruct = object({ + accountId: UuidStruct, +}); +export const DeleteAccountStruct = object({ + accountId: UuidStruct, +}); +export const ListAccountAssetsStruct = object({ + accountId: UuidStruct, +}); +export const GetAccountBalancesStruct = object({ + accountId: UuidStruct, + assets: array(CaipAssetTypeStruct), +}); +export const ListAccountTransactionsStruct = object({ + accountId: UuidStruct, + pagination: object({ + limit: integer(), + next: optional(nullable(string())), + }), +}); + +export const GetAccounBalancesResponseStruct = record( + CaipAssetTypeStruct, + object({ + amount: PositiveNumberStringStruct, + unit: string(), + }), +); + +export const ListAccountAssetsResponseStruct = array(CaipAssetTypeStruct); + +export const SubmitRequestMethodStruct = enums(Object.values(SolMethod)); + +export const NetworkStruct = enums(Object.values(Network)); + +export const Curenc = enums([ + 'btc', + 'eth', + 'ltc', + 'bch', + 'bnb', + 'eos', + 'xrp', + 'xlm', + 'link', + 'dot', + 'yfi', + 'usd', + 'aed', + 'ars', + 'aud', + 'bdt', + 'bhd', + 'bmd', + 'brl', + 'cad', + 'chf', + 'clp', + 'cny', + 'czk', + 'dkk', + 'eur', + 'gbp', + 'gel', + 'hkd', + 'huf', + 'idr', + 'ils', + 'inr', + 'jpy', + 'krw', + 'kwd', + 'lkr', + 'mmk', + 'mxn', + 'myr', + 'ngn', + 'nok', + 'nzd', + 'php', + 'pkr', + 'pln', + 'rub', + 'sar', + 'sek', + 'sgd', + 'thb', + 'try', + 'twd', + 'uah', + 'vef', + 'vnd', + 'zar', + 'xdr', + 'xag', + 'xau', + 'bits', + 'sats', +]); + +export const GetFeeForTransactionParamsStruct = object({ + transaction: string(), + scope: enums(Object.values(Network)), +}); + +export const GetFeeForTransactionResponseStruct = object({ + value: nullable(PositiveNumberStringStruct), +}); + +/** + * Validates if a string is Base58 encoded. + * Base58 uses alphanumeric characters excluding 0, O, I, and l. + */ +export const Base58Struct: Struct = define('Base58', (value) => { + const BASE_58_PATTERN = + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/u; + + // First check if it's a string + if (typeof value !== 'string') { + return `Expected a string, but received: ${typeof value}`; + } + + // Then check if it matches the Base58 pattern + if (!BASE_58_PATTERN.test(value)) { + return 'Expected a Base58 encoded string, but received a string with invalid characters'; + } + + return true; +}); + +/** + * Validates if a string is Base64 encoded. + * Base64 uses alphanumeric characters and +, /, and =. + * Empty strings are rejected. + */ +export const Base64Struct = pattern( + string(), + /^(?:[A-Za-z0-9+/]{4})+(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u, +); + +const DERIVATION_PATH_REGEX = /^m\/44'\/501'/u; + +/** + * Validates a Solana derivation path following the format: m/44'/501'/... + */ +export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); + +/** + * Validates an ISO 8601 date string. + */ +export const Iso8601Struct = pattern( + string(), + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/u, +); diff --git a/merged-packages/tron-wallet-snap/src/validation/validators.ts b/merged-packages/tron-wallet-snap/src/validation/validators.ts new file mode 100644 index 00000000..3893d8d5 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/validators.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-throw-literal */ +import { + InvalidParamsError, + SnapError, + UnauthorizedError, +} from '@metamask/snaps-sdk'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; + +import { originPermissions } from '../permissions'; + +export const validateOrigin = (origin: string, method: string): void => { + if (!origin) { + throw new UnauthorizedError('Origin not found'); + } + if (!originPermissions.get(origin)?.has(method)) { + throw new UnauthorizedError('Permission denied'); + } +}; + +/** + * Validates that the request parameters conform to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the request parameters. + * @param requestParams - The request parameters to validate. + * @param struct - The expected structure of the request parameters. + * @throws {typeof InvalidParamsError} If the request parameters do not conform to the expected structure. + */ +export function validateRequest>( + requestParams: Params, + struct: TStruct, +): asserts requestParams is Infer { + try { + assert(requestParams, struct); + } catch (error: any) { + throw new InvalidParamsError(error.message) as unknown as Error; + } +} + +/** + * Validates that the response conforms to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the response. + * @param response - The response to validate. + * @param struct - The expected structure of the response. + * @throws {SnapError} If the response does not conform to the expected structure. + */ +export function validateResponse>( + response: Params, + struct: TStruct, +): asserts response is Infer { + try { + assert(response, struct); + } catch (error) { + throw new SnapError('Invalid Response') as unknown as Error; + } +} diff --git a/merged-packages/tron-wallet-snap/tsconfig.json b/merged-packages/tron-wallet-snap/tsconfig.json new file mode 100644 index 00000000..99b45e34 --- /dev/null +++ b/merged-packages/tron-wallet-snap/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "resolveJsonModule": true /* lets us import JSON modules from within TypeScript modules. */, + "jsx": "react-jsx", + "jsxImportSource": "@metamask/snaps-sdk", + "exactOptionalPropertyTypes": false, + "types": ["jest"] + }, + "include": ["**/*.ts", "**/*.tsx", "locales/*.json"] +} From 105bd3a3c484d6d099232cfc9b166a75fa63c77b Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 15 Jul 2025 17:15:07 +0100 Subject: [PATCH 002/238] feat: add test dApp --- merged-packages/tron-wallet-snap/.env.example | 8 +- .../tron-wallet-snap/images/icon.svg | 12 +- .../tron-wallet-snap/jest.config.js | 2 +- .../tron-wallet-snap/locales/en.json | 149 ++++++ merged-packages/tron-wallet-snap/package.json | 2 +- .../tron-wallet-snap/snap.config.ts | 13 +- .../tron-wallet-snap/snap.manifest.json | 38 +- .../src/caching/InMemoryCache.ts | 2 +- .../src/caching/StateCache.test.ts | 24 +- .../src/caching/StateCache.ts | 52 +- .../tron-wallet-snap/src/caching/types.ts | 2 +- .../src/caching/useCache.test.ts | 2 +- .../tron-wallet-snap/src/caching/useCache.ts | 7 +- .../clients/price-api/PriceApiClient.test.ts | 143 ++++-- .../clients/price-api/mocks/exchange-rates.ts | 476 ++++++++++++++++++ .../tron-wallet-snap/src/constants/tron.ts | 2 + .../tron-wallet-snap/src/handlers/keyring.ts | 29 +- .../src/services/config/ConfigProvider.ts | 55 +- .../src/utils/getLowestUnusedIndex.test.ts | 60 --- .../tron-wallet-snap/src/utils/i18n.test.ts | 30 -- .../tron-wallet-snap/src/utils/logger.ts | 4 +- .../tron-wallet-snap/src/utils/mockLogger.ts | 9 + .../src/utils/serialization/deserialize.ts | 2 +- 23 files changed, 864 insertions(+), 259 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/locales/en.json create mode 100644 merged-packages/tron-wallet-snap/src/clients/price-api/mocks/exchange-rates.ts delete mode 100644 merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.test.ts delete mode 100644 merged-packages/tron-wallet-snap/src/utils/i18n.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/mockLogger.ts diff --git a/merged-packages/tron-wallet-snap/.env.example b/merged-packages/tron-wallet-snap/.env.example index 787de2a6..769dd70d 100644 --- a/merged-packages/tron-wallet-snap/.env.example +++ b/merged-packages/tron-wallet-snap/.env.example @@ -4,10 +4,10 @@ # - production before submitting a PR ENVIRONMENT=local -RPC_URL_MAINNET=https://api.trongrid.io -RPC_URL_SHASTA_TESTNET=https://api.shasta.trongrid.io/jsonrpc -RPC_URL_NILE_TESTNET=https://nile.trongrid.io -RPC_URL_LOCALNET=http://localhost:8899 +RPC_URL_LIST_MAINNET=https://api.trongrid.io +RPC_URL_LIST_NILE_TESTNET=https://nile.trongrid.io +RPC_URL_LIST_SHASTA_TESTNET=https://api.shasta.trongrid.io/jsonrpc +RPC_URL_LIST_LOCALNET=http://localhost:8899 EXPLORER_BASE_URL=https://tronscan.org diff --git a/merged-packages/tron-wallet-snap/images/icon.svg b/merged-packages/tron-wallet-snap/images/icon.svg index 5871de00..a73d6396 100644 --- a/merged-packages/tron-wallet-snap/images/icon.svg +++ b/merged-packages/tron-wallet-snap/images/icon.svg @@ -1,11 +1 @@ - - - - - - + \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/jest.config.js b/merged-packages/tron-wallet-snap/jest.config.js index e0442ef8..cc31c75b 100644 --- a/merged-packages/tron-wallet-snap/jest.config.js +++ b/merged-packages/tron-wallet-snap/jest.config.js @@ -11,4 +11,4 @@ const config = { testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'], }; -export default config; +module.exports = config; diff --git a/merged-packages/tron-wallet-snap/locales/en.json b/merged-packages/tron-wallet-snap/locales/en.json new file mode 100644 index 00000000..92359d14 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/en.json @@ -0,0 +1,149 @@ +{ + "locale": "en", + "messages": { + "reviewTransactionWarning": { + "message": "Review the transaction before proceeding" + }, + "from": { + "message": "From" + }, + "toAddress": { + "message": "To" + }, + "continue": { + "message": "Continue" + }, + "cancel": { + "message": "Cancel" + }, + "clear": { + "message": "Clear" + }, + "amount": { + "message": "Amount" + }, + "balance": { + "message": "Balance" + }, + "recipient": { + "message": "Recipient" + }, + "network": { + "message": "Network" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Transaction speed" + }, + "transactionSpeedTooltip": { + "message": "The estimated time of the transaction" + }, + "networkFee": { + "message": "Network fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Send" + }, + "asset": { + "message": "Asset" + }, + "sending": { + "message": "Sending" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "review": { + "message": "Review" + }, + "error": { + "message": "Error" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" + } + } +} diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 8059b648..6e317cb8 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -37,7 +37,7 @@ "devDependencies": { "@jest/globals": "^30.0.3", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^18.0.0", + "@metamask/keyring-api": "patch:@metamask/keyring-api@npm%3A18.0.0#~/.yarn/patches/@metamask-keyring-api-npm-18.0.0-1987f8da30.patch", "@metamask/keyring-snap-sdk": "^4.0.0", "@metamask/slip44": "^4.2.0", "@metamask/snaps-cli": "^8.1.0", diff --git a/merged-packages/tron-wallet-snap/snap.config.ts b/merged-packages/tron-wallet-snap/snap.config.ts index 2b200685..1f66a307 100644 --- a/merged-packages/tron-wallet-snap/snap.config.ts +++ b/merged-packages/tron-wallet-snap/snap.config.ts @@ -12,18 +12,21 @@ const config: SnapConfig = { environment: { ENVIRONMENT: process.env.ENVIRONMENT ?? '', // RPC - RPC_URL_MAINNET: process.env.RPC_URL_MAINNET ?? '', - RPC_URL_SHASTA_TESTNET: process.env.RPC_URL_SHASTA_TESTNET ?? '', - RPC_URL_NILE_TESTNET: process.env.RPC_URL_NILE_TESTNET ?? '', - RPC_URL_LOCALNET: process.env.RPC_URL_LOCALNET ?? '', + RPC_URL_LIST_MAINNET: process.env.RPC_URL_LIST_MAINNET ?? '', + RPC_URL_LIST_NILE_TESTNET: process.env.RPC_URL_LIST_NILE_TESTNET ?? '', + RPC_URL_LIST_SHASTA_TESTNET: process.env.RPC_URL_LIST_SHASTA_TESTNET ?? '', + RPC_URL_LIST_LOCALNET: process.env.RPC_URL_LIST_LOCALNET ?? '', // Block explorer EXPLORER_BASE_URL: process.env.EXPLORER_BASE_URL ?? '', // APIs PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL ?? '', + TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL ?? '', + STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL ?? '', + SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL ?? '', + NFT_API_BASE_URL: process.env.NFT_API_BASE_URL ?? '', LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL ?? '', }, polyfills: true, - experimental: { wasm: true }, }; export default config; diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 59ecd9be..cf7f2660 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "rwr8H8hRWo+jd8LfXglNcStMWVvlLn0iZbReS/pkBtc=", + "shasum": "5IPCMVax/lf1NsIQge8qz+4FMCjR9GIMhRv+wlqJOAI=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -16,9 +16,7 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": [ - "locales/en.json" - ] + "locales": ["locales/en.json"] }, "initialConnections": { "https://portfolio.metamask.io": {}, @@ -33,6 +31,7 @@ }, "endowment:keyring": { "allowedOrigins": [ + "http://localhost:3000", "https://portfolio.metamask.io", "https://portfolio-builds.metafi-dev.codefi.network", "https://dev.portfolio.metamask.io", @@ -41,39 +40,20 @@ }, "snap_getBip32Entropy": [ { - "path": [ - "m", - "44'", - "195'" - ], + "path": ["m", "44'", "195'"], "curve": "secp256k1" - }, + } ], "endowment:network-access": {}, "snap_manageAccounts": {}, "snap_manageState": {}, "snap_dialog": {}, "snap_getPreferences": {}, - "endowment:cronjob": { - "jobs": [ - { - "duration": "PT30S", - "request": { - "method": "synchronizeAccounts" - } - } - ] - }, + "endowment:cronjob": {}, "endowment:assets": { - "scopes": [ - "bip122:000000000019d6689c085ae165831e93", - "bip122:000000000933ea01ad0ee984209779ba", - "bip122:00000000da84f2bafbbc53dee25a72ae", - "bip122:00000008819873e925422c1ff0f99f7c", - "bip122:regtest" - ] + "scopes": ["tron:728126428", "tron:3448148188", "tron:2494104990"] } }, - "platformVersion": "9.0.0", + "platformVersion": "9.2.0", "manifestVersion": "0.1" -} \ No newline at end of file +} diff --git a/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts b/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts index 9f4a84c1..ba380573 100644 --- a/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts @@ -1,7 +1,7 @@ import { assert } from '@metamask/utils'; -import type { Serializable } from '../serialization/types'; import type { ILogger } from '../utils/logger'; +import type { Serializable } from '../utils/serialization/types'; import type { ICache } from './ICache'; import type { CacheEntry } from './types'; diff --git a/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts b/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts index 4c30ba69..ff6e4022 100644 --- a/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts +++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts @@ -192,10 +192,9 @@ describe('StateCache', () => { __cache__default: {}, }); const cache = new StateCache(stateWithCache); - jest + const mockDateNow = jest .spyOn(Date, 'now') - .mockReturnValueOnce(1704067200000) // January 1, 2024 - .mockReturnValueOnce(1704067200001); // January 1, 2024 + 1 millisecond + .mockReturnValue(1704067200000); // January 1, 2024 await cache.set('someKey', 'someValue', 0); const stateValue = await stateWithCache.get(); @@ -209,8 +208,13 @@ describe('StateCache', () => { }, }); + // Change the mock to return a time after the expiration + mockDateNow.mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + const value = await cache.get('someKey'); // Should expire immediately expect(value).toBeUndefined(); + + mockDateNow.mockRestore(); }); it('throws an error if the ttl is not a number', async () => { @@ -530,11 +534,18 @@ describe('StateCache', () => { }); const cache = new StateCache(stateWithCache); + // Mock Date.now to return a time after the expiration + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + const result = await cache.mget(['someKey']); expect(result).toEqual({ someKey: undefined, }); + + mockDateNow.mockRestore(); }); it('returns an empty object if the cache is not initialized', async () => { @@ -561,6 +572,11 @@ describe('StateCache', () => { }); const cache = new StateCache(stateWithCache); + // Mock Date.now to return a time after the expiration + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + await cache.mget(['someKey']); const stateValue = await stateWithCache.get(); @@ -572,6 +588,8 @@ describe('StateCache', () => { }, }, }); + + mockDateNow.mockRestore(); }); }); diff --git a/merged-packages/tron-wallet-snap/src/caching/StateCache.ts b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts index 988d5893..115b5fa5 100644 --- a/merged-packages/tron-wallet-snap/src/caching/StateCache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts @@ -2,9 +2,9 @@ import { assert } from '@metamask/utils'; -import type { Serializable } from '../serialization/types'; import type { IStateManager } from '../services/state/IStateManager'; import type { ILogger } from '../utils/logger'; +import type { Serializable } from '../utils/serialization/types'; import type { ICache } from './ICache'; import type { CacheEntry } from './types'; @@ -165,7 +165,12 @@ export class StateCache implements ICache { ): Promise> { const cacheStore = await this.#state.getKey(this.prefix); - const keysAndValues = Object.entries(cacheStore ?? {}).filter(([key]) => + // If cache is not initialized, return empty object + if (!cacheStore) { + return {}; + } + + const keysAndValues = Object.entries(cacheStore).filter(([key]) => keys.includes(key), ); @@ -175,25 +180,34 @@ export class StateCache implements ICache { await this.mdelete(expiredKeys.map(([key]) => key)); - return keysAndValues.reduce>( - (acc, [key, cacheEntry]) => { - if (cacheEntry === undefined) { - this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); - return acc; - } + const result: Record = {}; + + // First, handle keys that exist in the cache + keysAndValues.forEach(([key, cacheEntry]) => { + if (cacheEntry === undefined) { + this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + return; + } + + if (cacheEntry.expiresAt < Date.now()) { + this.logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); + result[key] = undefined; + } else { + this.logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); + result[key] = cacheEntry.value; + } + }); - if (cacheEntry.expiresAt < Date.now()) { - this.logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); - acc[key] = undefined; - } else { - this.logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); - acc[key] = cacheEntry.value; - } + // Then, handle keys that don't exist in the cache + keys.forEach((key) => { + if (!(key in result)) { + this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + } + }); - return acc; - }, - {}, - ); + return result; } async mset( diff --git a/merged-packages/tron-wallet-snap/src/caching/types.ts b/merged-packages/tron-wallet-snap/src/caching/types.ts index a06842d0..f20203dc 100644 --- a/merged-packages/tron-wallet-snap/src/caching/types.ts +++ b/merged-packages/tron-wallet-snap/src/caching/types.ts @@ -1,4 +1,4 @@ -import type { Serializable } from '../serialization/types'; +import type { Serializable } from '../utils/serialization/types'; export type TimestampMilliseconds = number; diff --git a/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts b/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts index 183940f2..3b438d99 100644 --- a/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts +++ b/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts @@ -1,4 +1,4 @@ -import type { Serializable } from '../serialization/types'; +import { Serializable } from '../utils/serialization/types'; import type { ICache } from './ICache'; import { useCache, type CacheOptions } from './useCache'; diff --git a/merged-packages/tron-wallet-snap/src/caching/useCache.ts b/merged-packages/tron-wallet-snap/src/caching/useCache.ts index 9e4a7ac0..a31d798d 100644 --- a/merged-packages/tron-wallet-snap/src/caching/useCache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/useCache.ts @@ -1,6 +1,7 @@ /* eslint-disable no-void */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Serializable } from '../serialization/types'; +import logger from '../utils/logger'; +import type { Serializable } from '../utils/serialization/types'; import type { ICache } from './ICache'; /** @@ -69,7 +70,7 @@ export const useCache = ( } } catch (error) { // Log cache get errors but proceed to execute the function - console.error(`Cache get error for key "${cacheKey}":`, error); + logger.error(`Cache get error for key "${cacheKey}":`, error); } // Execute the original function @@ -78,7 +79,7 @@ export const useCache = ( // Cache the result, handle potential errors silently // We don't await this, allowing it to happen in the background void cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { - console.error(`Cache set error for key "${cacheKey}":`, error); + logger.error(`Cache set error for key "${cacheKey}":`, error); }); return result; diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts index 10527e78..c862a767 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts @@ -4,13 +4,12 @@ import { cloneDeep } from 'lodash'; import type { ICache } from '../../caching/ICache'; import { InMemoryCache } from '../../caching/InMemoryCache'; -import { KnownCaip19Id } from '../../constants/solana'; -import type { Serializable } from '../../serialization/types'; +import { KnownCaip19Id } from '../../constants/tron'; import type { ConfigProvider } from '../../services/config'; -import { mockLogger } from '../../services/mocks/logger'; -import { MOCK_EXCHANGE_RATES } from '../../test/mocks/price-api/exchange-rates'; +import { mockLogger } from '../../utils/mockLogger'; +import type { Serializable } from '../../utils/serialization/types'; +import { MOCK_EXCHANGE_RATES } from './mocks/exchange-rates'; import { MOCK_HISTORICAL_PRICES } from './mocks/historical-prices'; -import { MOCK_SPOT_PRICES } from './mocks/spot-prices'; import { PriceApiClient } from './PriceApiClient'; import type { SpotPrices, VsCurrencyParam } from './types'; @@ -64,8 +63,58 @@ describe('PriceApiClient', () => { describe('getMultipleSpotPrices', () => { const mockResponse: SpotPrices = { - [KnownCaip19Id.SolMainnet]: MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, - [KnownCaip19Id.UsdcMainnet]: MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + [KnownCaip19Id.TrxMainnet]: { + id: 'tron', + price: 0.123456789, + marketCap: 1234567890.123456789, + allTimeHigh: 0.5, + allTimeLow: 0.01, + totalVolume: 123456789.123456789, + high1d: 0.13, + low1d: 0.12, + circulatingSupply: 1000000000, + dilutedMarketCap: 1234567890.123456789, + marketCapPercentChange1d: 1.5, + priceChange1d: 0.01, + pricePercentChange1h: 0.5, + pricePercentChange1d: 1.0, + pricePercentChange7d: -2.0, + pricePercentChange14d: 3.0, + pricePercentChange30d: -1.0, + pricePercentChange200d: 10.0, + pricePercentChange1y: 20.0, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, + [KnownCaip19Id.UsdtMainnet]: { + id: 'tether', + price: 1.0, + marketCap: 1000000000.0, + allTimeHigh: 1.1, + allTimeLow: 0.9, + totalVolume: 50000000.0, + high1d: 1.01, + low1d: 0.99, + circulatingSupply: 1000000000, + dilutedMarketCap: 1000000000.0, + marketCapPercentChange1d: 0.1, + priceChange1d: 0.001, + pricePercentChange1h: 0.01, + pricePercentChange1d: 0.1, + pricePercentChange7d: 0.0, + pricePercentChange14d: 0.0, + pricePercentChange30d: 0.0, + pricePercentChange200d: 0.0, + pricePercentChange1y: 0.0, + bondingCurveProgressPercent: null, + liquidity: null, + totalSupply: null, + holderCount: null, + isMutable: null, + }, }; describe('when the data is not cached', () => { @@ -76,12 +125,12 @@ describe('PriceApiClient', () => { }); const result = await client.getMultipleSpotPrices([ - KnownCaip19Id.SolMainnet, - KnownCaip19Id.UsdcMainnet, + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.UsdtMainnet, ]); expect(mockFetch).toHaveBeenCalledWith( - 'https://some-mock-url.com/v3/spot-prices?vsCurrency=usd&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Fslip44%3A501%2Csolana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=usd&assetIds=tron%3A728126428%2Fslip44%3A195%2Ctron%3A728126428%2Ftrc20%3ATR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&includeMarketData=true', ); expect(result).toStrictEqual(mockResponse); }); @@ -92,8 +141,8 @@ describe('PriceApiClient', () => { await expect( client.getMultipleSpotPrices([ - KnownCaip19Id.SolLocalnet, - KnownCaip19Id.UsdcLocalnet, + KnownCaip19Id.TrxLocalnet, + KnownCaip19Id.UsdtMainnet, ]), ).rejects.toThrow('Fetch failed'); expect(mockLogger.error).toHaveBeenCalledWith( @@ -110,8 +159,8 @@ describe('PriceApiClient', () => { await expect( client.getMultipleSpotPrices([ - KnownCaip19Id.SolLocalnet, - KnownCaip19Id.UsdcLocalnet, + KnownCaip19Id.TrxLocalnet, + KnownCaip19Id.UsdtMainnet, ]), ).rejects.toThrow('HTTP error! status: 404'); expect(mockLogger.error).toHaveBeenCalledWith( @@ -123,16 +172,16 @@ describe('PriceApiClient', () => { it('fetches spot price with custom vsCurrency', async () => { mockFetch.mockResolvedValueOnce({ ok: true, - json: jest.fn().mockResolvedValueOnce(MOCK_SPOT_PRICES), + json: jest.fn().mockResolvedValueOnce(mockResponse), }); await client.getMultipleSpotPrices( - [KnownCaip19Id.SolMainnet, KnownCaip19Id.UsdcMainnet], + [KnownCaip19Id.TrxMainnet, KnownCaip19Id.UsdtMainnet], 'eur', ); expect(mockFetch).toHaveBeenCalledWith( - 'https://some-mock-url.com/v3/spot-prices?vsCurrency=eur&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Fslip44%3A501%2Csolana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=eur&assetIds=tron%3A728126428%2Fslip44%3A195%2Ctron%3A728126428%2Ftrc20%3ATR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&includeMarketData=true', ); }); @@ -144,8 +193,8 @@ describe('PriceApiClient', () => { await expect( client.getMultipleSpotPrices([ - KnownCaip19Id.SolLocalnet, - KnownCaip19Id.UsdcLocalnet, + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.UsdtMainnet, ]), ).rejects.toThrow('Invalid JSON'); expect(mockLogger.error).toHaveBeenCalledWith( @@ -159,8 +208,8 @@ describe('PriceApiClient', () => { await expect( client.getMultipleSpotPrices([ - KnownCaip19Id.SolLocalnet, - KnownCaip19Id.UsdcLocalnet, + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.UsdtMainnet, ]), ).rejects.toThrow('Network timeout'); expect(mockLogger.error).toHaveBeenCalledWith( @@ -171,7 +220,7 @@ describe('PriceApiClient', () => { it('throws when malformed response from the Price API', async () => { const mockMalformedResponse = cloneDeep(mockResponse); - mockMalformedResponse[KnownCaip19Id.SolMainnet]!.price = -999; // Price must be a positive number + mockMalformedResponse[KnownCaip19Id.TrxMainnet]!.price = -999; // Price must be a positive number mockFetch.mockResolvedValueOnce({ ok: true, @@ -179,9 +228,9 @@ describe('PriceApiClient', () => { }); await expect( - client.getMultipleSpotPrices([KnownCaip19Id.SolMainnet]), + client.getMultipleSpotPrices([KnownCaip19Id.TrxMainnet]), ).rejects.toThrow( - 'At path: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501.price -- Expected a number greater than or equal to 0 but received `-999`', + 'At path: tron:728126428/slip44:195.price -- Expected a number greater than or equal to 0 but received `-999`', ); }); }); @@ -189,17 +238,17 @@ describe('PriceApiClient', () => { describe('when the data is fully cached', () => { it('returns the cached data', async () => { const cachedData = { - 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501:usd': - MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, - 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v:usd': - MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + 'PriceApiClient:getMultipleSpotPrices:tron:728126428/slip44:195:usd': + mockResponse[KnownCaip19Id.TrxMainnet]!, + 'PriceApiClient:getMultipleSpotPrices:tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t:usd': + mockResponse[KnownCaip19Id.UsdtMainnet]!, }; jest.spyOn(mockCache, 'mget').mockResolvedValueOnce(cachedData); jest.spyOn(mockCache, 'mset').mockResolvedValueOnce(undefined); const result = await client.getMultipleSpotPrices([ - KnownCaip19Id.SolMainnet, - KnownCaip19Id.UsdcMainnet, + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.UsdtMainnet, ]); expect(result).toStrictEqual(mockResponse); @@ -212,36 +261,36 @@ describe('PriceApiClient', () => { it('returns the cached data', async () => { // Only the first token is cached, we will need to fetch the second one const cachedData = { - 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501:usd': - MOCK_SPOT_PRICES[KnownCaip19Id.SolMainnet]!, + 'PriceApiClient:getMultipleSpotPrices:tron:728126428/slip44:195:usd': + mockResponse[KnownCaip19Id.TrxMainnet]!, }; jest.spyOn(mockCache, 'mget').mockResolvedValueOnce(cachedData); jest.spyOn(mockCache, 'mset').mockResolvedValueOnce(undefined); mockFetch.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValueOnce({ - [KnownCaip19Id.UsdcMainnet]: - MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + [KnownCaip19Id.UsdtMainnet]: + mockResponse[KnownCaip19Id.UsdtMainnet]!, }), }); const result = await client.getMultipleSpotPrices([ - KnownCaip19Id.SolMainnet, - KnownCaip19Id.UsdcMainnet, + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.UsdtMainnet, ]); expect(result).toStrictEqual(mockResponse); // We should have fetched the second token only expect(mockFetch).toHaveBeenCalledWith( - 'https://some-mock-url.com/v3/spot-prices?vsCurrency=usd&assetIds=solana%3A5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp%2Ftoken%3AEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&includeMarketData=true', + 'https://some-mock-url.com/v3/spot-prices?vsCurrency=usd&assetIds=tron%3A728126428%2Ftrc20%3ATR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&includeMarketData=true', ); // The second token should be added to the cache expect(mockCache.mset).toHaveBeenCalledWith([ { - key: 'PriceApiClient:getMultipleSpotPrices:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v:usd', - value: MOCK_SPOT_PRICES[KnownCaip19Id.UsdcMainnet]!, + key: 'PriceApiClient:getMultipleSpotPrices:tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t:usd', + value: mockResponse[KnownCaip19Id.UsdtMainnet]!, ttlMilliseconds: 0, }, ]); @@ -279,7 +328,7 @@ describe('PriceApiClient', () => { it('rejects tokenCaipAssetTypes that are invalid or that include malicious inputs', async () => { await expect( client.getMultipleSpotPrices([ - KnownCaip19Id.SolLocalnet, + KnownCaip19Id.TrxLocalnet, 'INVALID' as CaipAssetType, ]), ).rejects.toThrow( @@ -290,7 +339,7 @@ describe('PriceApiClient', () => { it('rejects vsCurrency parameters that are invalid or that include malicious inputs', async () => { await expect( client.getMultipleSpotPrices( - [KnownCaip19Id.SolLocalnet], + [KnownCaip19Id.TrxLocalnet], 'INVALID' as VsCurrencyParam, ), ).rejects.toThrow(/Expected/u); @@ -302,7 +351,7 @@ describe('PriceApiClient', () => { json: jest.fn().mockResolvedValueOnce({}), }); - await client.getMultipleSpotPrices([KnownCaip19Id.SolLocalnet]); + await client.getMultipleSpotPrices([KnownCaip19Id.TrxLocalnet]); // Verify URL is properly constructed with encoded parameters expect(mockFetch).toHaveBeenCalledWith( @@ -320,7 +369,7 @@ describe('PriceApiClient', () => { await expect( client.getMultipleSpotPrices( - [KnownCaip19Id.SolLocalnet], + [KnownCaip19Id.TrxLocalnet], 'usd\x00\x1F' as VsCurrencyParam, ), ).rejects.toThrow(/Expected/u); @@ -338,7 +387,7 @@ describe('PriceApiClient', () => { const cacheSetSpy = jest.spyOn(mockCache, 'set'); const result = await client.getHistoricalPrices({ - assetType: KnownCaip19Id.SolMainnet, + assetType: KnownCaip19Id.TrxMainnet, timePeriod: '5d', from: 123, to: 456, @@ -346,10 +395,10 @@ describe('PriceApiClient', () => { }); expect(mockFetch).toHaveBeenCalledWith( - 'https://some-mock-url.com/v3/historical-prices/solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501?timePeriod=5d&from=123&to=456&vsCurrency=usd', + 'https://some-mock-url.com/v3/historical-prices/tron:728126428/slip44:195?timePeriod=5d&from=123&to=456&vsCurrency=usd', ); expect(cacheSetSpy).toHaveBeenCalledWith( - 'PriceApiClient:getHistoricalPrices:{"assetType":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501","timePeriod":"5d","from":123,"to":456,"vsCurrency":"usd"}', + 'PriceApiClient:getHistoricalPrices:{"assetType":"tron:728126428/slip44:195","timePeriod":"5d","from":123,"to":456,"vsCurrency":"usd"}', MOCK_HISTORICAL_PRICES, 0, ); @@ -367,7 +416,7 @@ describe('PriceApiClient', () => { const cacheSetSpy = jest.spyOn(mockCache, 'set'); const result = await client.getHistoricalPrices({ - assetType: KnownCaip19Id.SolMainnet, + assetType: KnownCaip19Id.TrxMainnet, timePeriod: '5d', from: 123, to: 456, diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/exchange-rates.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/exchange-rates.ts new file mode 100644 index 00000000..dc9d2dfc --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/mocks/exchange-rates.ts @@ -0,0 +1,476 @@ +import type { ExchangeRate, Ticker } from '../types'; + +/** + * HEADS UP! Changing this mock MUST involve changing the spot prices mock too! + * Their values are interdependent and essential for the TokenPricesService tests. + */ +export const MOCK_EXCHANGE_RATES: Record = { + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.000009225522122806664, + currencyType: 'crypto', + }, + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.0004032198954215109, + currencyType: 'crypto', + }, + ltc: { + name: 'Litecoin', + ticker: 'ltc', + value: 0.011656225789635273, + currencyType: 'crypto', + }, + bch: { + name: 'Bitcoin Cash', + ticker: 'bch', + value: 0.001982942950598187, + currencyType: 'crypto', + }, + bnb: { + name: 'Binance Coin', + ticker: 'bnb', + value: 0.0015156056764231698, + currencyType: 'crypto', + }, + eos: { + name: 'EOS', + ticker: 'eos', + value: 2.056880058128908, + currencyType: 'crypto', + }, + xrp: { + name: 'XRP', + ticker: 'xrp', + value: 0.4540842119866674, + currencyType: 'crypto', + }, + xlm: { + name: 'Lumens', + ticker: 'xlm', + value: 4.29161071887215, + currencyType: 'crypto', + }, + link: { + name: 'Chainlink', + ticker: 'link', + value: 0.07546219704388624, + currencyType: 'crypto', + }, + dot: { + name: 'Polkadot', + ticker: 'dot', + value: 0.29389602831032285, + currencyType: 'crypto', + }, + yfi: { + name: 'Yearn.finance', + ticker: 'yfi', + value: 0.00019925282680837832, + currencyType: 'crypto', + }, + usd: { + name: 'US Dollar', + ticker: 'usd', + value: 1, + currencyType: 'fiat', + }, + aed: { + name: 'United Arab Emirates Dirham', + ticker: 'aed', + value: 3.6730349953852555, + currencyType: 'fiat', + }, + ars: { + name: 'Argentine Peso', + ticker: 'ars', + value: 1206.0000013561519, + currencyType: 'fiat', + }, + aud: { + name: 'Australian Dollar', + ticker: 'aud', + value: 1.5232439935923583, + currencyType: 'fiat', + }, + bdt: { + name: 'Bangladeshi Taka', + ticker: 'bdt', + value: 122.29205113607277, + currencyType: 'fiat', + }, + bhd: { + name: 'Bahraini Dinar', + ticker: 'bhd', + value: 0.3769909979846017, + currencyType: 'fiat', + }, + bmd: { + name: 'Bermudian Dollar', + ticker: 'bmd', + value: 1, + currencyType: 'fiat', + }, + brl: { + name: 'Brazil Real', + ticker: 'brl', + value: 5.446300002410629, + currencyType: 'fiat', + }, + cad: { + name: 'Canadian Dollar', + ticker: 'cad', + value: 1.3640219988479354, + currencyType: 'fiat', + }, + chf: { + name: 'Swiss Franc', + ticker: 'chf', + value: 0.7936309928980179, + currencyType: 'fiat', + }, + clp: { + name: 'Chilean Peso', + ticker: 'clp', + value: 923.830001036303, + currencyType: 'fiat', + }, + cny: { + name: 'Chinese Yuan', + ticker: 'cny', + value: 7.166700000015684, + currencyType: 'fiat', + }, + czk: { + name: 'Czech Koruna', + ticker: 'czk', + value: 20.952984017733154, + currencyType: 'fiat', + }, + dkk: { + name: 'Danish Krone', + ticker: 'dkk', + value: 6.339276002611524, + currencyType: 'fiat', + }, + eur: { + name: 'Euro', + ticker: 'eur', + value: 0.8496419976174352, + currencyType: 'fiat', + }, + gbp: { + name: 'British Pound Sterling', + ticker: 'gbp', + value: 0.7356629966217338, + currencyType: 'fiat', + }, + gel: { + name: 'Georgian Lari', + ticker: 'gel', + value: 2.719999997416854, + currencyType: 'fiat', + }, + hkd: { + name: 'Hong Kong Dollar', + ticker: 'hkd', + value: 7.84986500616371, + currencyType: 'fiat', + }, + huf: { + name: 'Hungarian Forint', + ticker: 'huf', + value: 340.2474533753413, + currencyType: 'fiat', + }, + idr: { + name: 'Indonesian Rupiah', + ticker: 'idr', + value: 16212.776418318166, + currencyType: 'fiat', + }, + ils: { + name: 'Israeli New Shekel', + ticker: 'ils', + value: 3.3717049952207647, + currencyType: 'fiat', + }, + inr: { + name: 'Indian Rupee', + ticker: 'inr', + value: 85.59833408842695, + currencyType: 'fiat', + }, + jpy: { + name: 'Japanese Yen', + ticker: 'jpy', + value: 143.9902001614485, + currencyType: 'fiat', + }, + krw: { + name: 'South Korean Won', + ticker: 'krw', + value: 1359.3506945328236, + currencyType: 'fiat', + }, + kwd: { + name: 'Kuwaiti Dinar', + ticker: 'kwd', + value: 0.30529199289535164, + currencyType: 'fiat', + }, + lkr: { + name: 'Sri Lankan Rupee', + ticker: 'lkr', + value: 299.9010793298127, + currencyType: 'fiat', + }, + mmk: { + name: 'Burmese Kyat', + ticker: 'mmk', + value: 2098.0000023617336, + currencyType: 'fiat', + }, + mxn: { + name: 'Mexican Peso', + ticker: 'mxn', + value: 18.77348001704397, + currencyType: 'fiat', + }, + myr: { + name: 'Malaysian Ringgit', + ticker: 'myr', + value: 4.228999997038608, + currencyType: 'fiat', + }, + ngn: { + name: 'Nigerian Naira', + ticker: 'ngn', + value: 1532.4200017290473, + currencyType: 'fiat', + }, + nok: { + name: 'Norwegian Krone', + ticker: 'nok', + value: 10.109898008254978, + currencyType: 'fiat', + }, + nzd: { + name: 'New Zealand Dollar', + ticker: 'nzd', + value: 1.6478669960903807, + currencyType: 'fiat', + }, + php: { + name: 'Philippine Peso', + ticker: 'php', + value: 56.376001062558736, + currencyType: 'fiat', + }, + pkr: { + name: 'Pakistani Rupee', + ticker: 'pkr', + value: 285.2245003132019, + currencyType: 'fiat', + }, + pln: { + name: 'Polish Zloty', + ticker: 'pln', + value: 3.625871995197858, + currencyType: 'fiat', + }, + rub: { + name: 'Russian Ruble', + ticker: 'rub', + value: 78.79997408366326, + currencyType: 'fiat', + }, + sar: { + name: 'Saudi Riyal', + ticker: 'sar', + value: 3.7501600005365567, + currencyType: 'fiat', + }, + sek: { + name: 'Swedish Krona', + ticker: 'sek', + value: 9.55167101005786, + currencyType: 'fiat', + }, + sgd: { + name: 'Singapore Dollar', + ticker: 'sgd', + value: 1.2739619998345126, + currencyType: 'fiat', + }, + thb: { + name: 'Thai Baht', + ticker: 'thb', + value: 32.40583303378832, + currencyType: 'fiat', + }, + try: { + name: 'Turkish Lira', + ticker: 'try', + value: 39.788298041452094, + currencyType: 'fiat', + }, + twd: { + name: 'New Taiwan Dollar', + ticker: 'twd', + value: 29.018999031034188, + currencyType: 'fiat', + }, + uah: { + name: 'Ukrainian hryvnia', + ticker: 'uah', + value: 41.75092204711494, + currencyType: 'fiat', + }, + vef: { + name: 'Venezuelan bolívar fuerte', + ticker: 'vef', + value: 0.10012999775478468, + currencyType: 'fiat', + }, + vnd: { + name: 'Vietnamese đồng', + ticker: 'vnd', + value: 26167.73565956473, + currencyType: 'fiat', + }, + zar: { + name: 'South African Rand', + ticker: 'zar', + value: 17.638879012711193, + currencyType: 'fiat', + }, + xdr: { + name: 'IMF Special Drawing Rights', + ticker: 'xdr', + value: 0.6961849947454656, + currencyType: 'fiat', + }, + xag: { + name: 'Silver - Troy Ounce', + ticker: 'xag', + value: 0.02745114996087133, + currencyType: 'commodity', + }, + xau: { + name: 'Gold - Troy Ounce', + ticker: 'xau', + value: 0.0002992943887080938, + currencyType: 'commodity', + }, + bits: { + name: 'Bits', + ticker: 'bits', + value: 9.225522122806664, + currencyType: 'crypto', + }, + sats: { + name: 'Satoshi', + ticker: 'sats', + value: 922.5522122806664, + currencyType: 'crypto', + }, + cop: { + name: 'Colombian Peso', + ticker: 'cop', + value: 4020.329999998432, + currencyType: 'fiat', + }, + kes: { + name: 'Kenyan Shilling', + ticker: 'kes', + value: 129.20000000184513, + currencyType: 'fiat', + }, + ron: { + name: 'Romanian Leu', + ticker: 'ron', + value: 4.302400003896861, + currencyType: 'fiat', + }, + dop: { + name: 'Dominican Peso', + ticker: 'dop', + value: 59.421077000552856, + currencyType: 'fiat', + }, + crc: { + name: 'Costa Rican Colón', + ticker: 'crc', + value: 505.1511230011281, + currencyType: 'fiat', + }, + hnl: { + name: 'Honduran Lempira', + ticker: 'hnl', + value: 26.133209998558144, + currencyType: 'fiat', + }, + zmw: { + name: 'Zambian Kwacha', + ticker: 'zmw', + value: 24.02423300185325, + currencyType: 'fiat', + }, + svc: { + name: 'Salvadoran Colón', + ticker: 'svc', + value: 8.749590998008589, + currencyType: 'fiat', + }, + bam: { + name: 'Bosnia and Herzegovina Convertible Mark', + ticker: 'bam', + value: 1.6618870036093658, + currencyType: 'fiat', + }, + pen: { + name: 'Peruvian Sol', + ticker: 'pen', + value: 3.5611860013883123, + currencyType: 'fiat', + }, + gtq: { + name: 'Guatemalan Quetzal', + ticker: 'gtq', + value: 7.688288003161476, + currencyType: 'fiat', + }, + lbp: { + name: 'Lebanese Pound', + ticker: 'lbp', + value: 89577.29288500333, + currencyType: 'fiat', + }, + amd: { + name: 'Armenian Dram', + ticker: 'amd', + value: 384.5100000000923, + currencyType: 'fiat', + }, + sol: { + name: 'Solana', + ticker: 'sol', + value: 0.006629188747026665, + currencyType: 'crypto', + }, + sei: { + name: 'Sei Network', + ticker: 'sei', + value: 3.571422841670739, + currencyType: 'crypto', + }, + sonic: { + name: 'Sonic', + ticker: 'sonic', + value: 3.0932878113426843, + currencyType: 'crypto', + }, +}; diff --git a/merged-packages/tron-wallet-snap/src/constants/tron.ts b/merged-packages/tron-wallet-snap/src/constants/tron.ts index d7295bf4..6742c55c 100644 --- a/merged-packages/tron-wallet-snap/src/constants/tron.ts +++ b/merged-packages/tron-wallet-snap/src/constants/tron.ts @@ -12,6 +12,8 @@ export enum KnownCaip19Id { TrxNile = `${Network.Nile}/slip44:195`, TrxShasta = `${Network.Shasta}/slip44:195`, TrxLocalnet = `${Network.Localnet}/slip44:195`, + + UsdtMainnet = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, } export const TokenMetadata = { diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index dd21119d..592cccab 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -30,6 +30,8 @@ import type { JsonRpcRequest, } from '@metamask/utils'; +import { SnapError } from '@metamask/snaps-sdk'; +import { sortBy } from 'lodash'; import type { TronKeyringAccount } from '../entities'; import { asStrictKeyringAccount } from '../entities/keyring-account'; import { AssetsService } from '../services/assets/AssetsService'; @@ -77,11 +79,32 @@ export class KeyringHandler implements Keyring { } async listAccounts(): Promise { - throw new Error('Method not implemented.'); + try { + const keyringAccounts = + (await this.#state.getKey( + 'keyringAccounts', + )) ?? {}; + + return sortBy(Object.values(keyringAccounts), ['entropySource', 'index']); + } catch (error: any) { + this.#logger.error({ error }, 'Error listing accounts'); + throw new Error('Error listing accounts'); + } } - async getAccount(id: string): Promise { - throw new Error('Method not implemented.'); + async getAccount( + accountId: string, + ): Promise { + try { + const account = await this.#state.getKey( + `keyringAccounts.${accountId}`, + ); + + return account; + } catch (error: any) { + this.#logger.error({ error }, 'Error getting account'); + throw new SnapError(error); + } } #getLowestUnusedKeyringAccountIndex( diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index 8a9d98ac..ab3d5c7a 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -6,7 +6,7 @@ import { create, enums, object, - string, + string } from '@metamask/superstruct'; import { Duration } from '@metamask/utils'; @@ -33,14 +33,10 @@ const CommaSeparatedListOfStringsStruct = coerce( const EnvStruct = object({ ENVIRONMENT: enums(['local', 'test', 'production']), - RPC_URL_MAINNET_LIST: CommaSeparatedListOfUrlsStruct, - RPC_URL_DEVNET_LIST: CommaSeparatedListOfUrlsStruct, - RPC_URL_TESTNET_LIST: CommaSeparatedListOfUrlsStruct, - RPC_URL_LOCALNET_LIST: CommaSeparatedListOfStringsStruct, - RPC_WEB_SOCKET_URL_MAINNET: UrlStruct, - RPC_WEB_SOCKET_URL_DEVNET: UrlStruct, - RPC_WEB_SOCKET_URL_TESTNET: UrlStruct, - RPC_WEB_SOCKET_URL_LOCALNET: UrlStruct, + RPC_URL_LIST_MAINNET: CommaSeparatedListOfUrlsStruct, + RPC_URL_LIST_NILE_TESTNET: CommaSeparatedListOfUrlsStruct, + RPC_URL_LIST_SHASTA_TESTNET: CommaSeparatedListOfUrlsStruct, + RPC_URL_LIST_LOCALNET: CommaSeparatedListOfStringsStruct, EXPLORER_BASE_URL: UrlStruct, PRICE_API_BASE_URL: UrlStruct, TOKEN_API_BASE_URL: UrlStruct, @@ -54,7 +50,6 @@ export type Env = Infer; export type NetworkConfig = (typeof Networks)[Network] & { rpcUrls: string[]; - webSocketUrl: string; }; export type Config = { @@ -91,10 +86,6 @@ export type Config = { getNftMetadata: number; }; }; - subscription: { - maxReconnectAttempts: number; - reconnectDelayMilliseconds: number; - }; }; /** @@ -118,22 +109,20 @@ export class ConfigProvider { #parseEnvironment() { const rawEnvironment = { ENVIRONMENT: process.env.ENVIRONMENT, - RPC_URL_MAINNET_LIST: process.env.RPC_URL_MAINNET_LIST, - RPC_URL_DEVNET_LIST: process.env.RPC_URL_DEVNET_LIST, - RPC_URL_TESTNET_LIST: process.env.RPC_URL_TESTNET_LIST, - RPC_URL_LOCALNET_LIST: process.env.RPC_URL_LOCALNET_LIST, - RPC_WEB_SOCKET_URL_MAINNET: process.env.RPC_WEB_SOCKET_URL_MAINNET, - RPC_WEB_SOCKET_URL_DEVNET: process.env.RPC_WEB_SOCKET_URL_DEVNET, - RPC_WEB_SOCKET_URL_TESTNET: process.env.RPC_WEB_SOCKET_URL_TESTNET, - RPC_WEB_SOCKET_URL_LOCALNET: process.env.RPC_WEB_SOCKET_URL_LOCALNET, + // RPC + RPC_URL_LIST_MAINNET: process.env.RPC_URL_LIST_MAINNET, + RPC_URL_LIST_NILE_TESTNET: process.env.RPC_URL_NILE_LIST_TESTNET, + RPC_URL_LIST_SHASTA_TESTNET: process.env.RPC_URL_LIST_SHASTA_TESTNET, + RPC_URL_LIST_LOCALNET: process.env.RPC_URL_LIST_LOCALNET, + // Block explorer EXPLORER_BASE_URL: process.env.EXPLORER_BASE_URL, + // APIs PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL, TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL, STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL, - SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL, // Blockaid - LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL, - // NFT API + SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL, NFT_API_BASE_URL: process.env.NFT_API_BASE_URL, + LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL, }; // Validate and parse them before returning @@ -146,23 +135,19 @@ export class ConfigProvider { networks: [ { ...Networks[Network.Mainnet], - rpcUrls: environment.RPC_URL_MAINNET_LIST, - webSocketUrl: environment.RPC_WEB_SOCKET_URL_MAINNET, + rpcUrls: environment.RPC_URL_LIST_MAINNET, }, { ...Networks[Network.Nile], - rpcUrls: environment.RPC_URL_DEVNET_LIST, - webSocketUrl: environment.RPC_WEB_SOCKET_URL_DEVNET, + rpcUrls: environment.RPC_URL_LIST_NILE_TESTNET, }, { ...Networks[Network.Shasta], - rpcUrls: environment.RPC_URL_TESTNET_LIST, - webSocketUrl: environment.RPC_WEB_SOCKET_URL_TESTNET, + rpcUrls: environment.RPC_URL_LIST_SHASTA_TESTNET, }, { ...Networks[Network.Localnet], - rpcUrls: environment.RPC_URL_LOCALNET_LIST, - webSocketUrl: environment.RPC_WEB_SOCKET_URL_LOCALNET, + rpcUrls: environment.RPC_URL_LIST_LOCALNET, }, ], explorerBaseUrl: environment.EXPLORER_BASE_URL, @@ -208,10 +193,6 @@ export class ConfigProvider { getNftMetadata: Duration.Minute, }, }, - subscription: { - maxReconnectAttempts: 5, - reconnectDelayMilliseconds: Duration.Second, - }, }; } diff --git a/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.test.ts b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.test.ts deleted file mode 100644 index 2ceafcee..00000000 --- a/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - MOCK_SOLANA_KEYRING_ACCOUNT_0, - MOCK_SOLANA_KEYRING_ACCOUNT_1, - MOCK_SOLANA_KEYRING_ACCOUNT_2, - MOCK_SOLANA_KEYRING_ACCOUNT_3, - MOCK_SOLANA_KEYRING_ACCOUNT_4, - MOCK_SOLANA_KEYRING_ACCOUNT_5, -} from '../test/mocks/solana-keyring-accounts'; -import { getLowestUnusedIndex } from './getLowestUnusedIndex'; - -describe('getLowestUnusedIndex', () => { - it('returns 0 when no accounts exist', () => { - expect(getLowestUnusedIndex([])).toBe(0); - }); - - it('returns 1 when only index 0 is used', () => { - const accounts = [MOCK_SOLANA_KEYRING_ACCOUNT_0]; - expect(getLowestUnusedIndex(accounts)).toBe(1); - }); - - it('finds gap in sequential indices', () => { - // Gap at index 2 - const accounts = [ - MOCK_SOLANA_KEYRING_ACCOUNT_0, - MOCK_SOLANA_KEYRING_ACCOUNT_1, - MOCK_SOLANA_KEYRING_ACCOUNT_3, - MOCK_SOLANA_KEYRING_ACCOUNT_4, - ]; - expect(getLowestUnusedIndex(accounts)).toBe(2); - }); - - it('handles unordered indices', () => { - const accounts = [ - MOCK_SOLANA_KEYRING_ACCOUNT_3, - MOCK_SOLANA_KEYRING_ACCOUNT_0, - MOCK_SOLANA_KEYRING_ACCOUNT_1, - MOCK_SOLANA_KEYRING_ACCOUNT_4, - ]; - expect(getLowestUnusedIndex(accounts)).toBe(2); - }); - - it('returns next number after continuous sequence', () => { - const accounts = [ - MOCK_SOLANA_KEYRING_ACCOUNT_0, - MOCK_SOLANA_KEYRING_ACCOUNT_1, - MOCK_SOLANA_KEYRING_ACCOUNT_2, - ]; - expect(getLowestUnusedIndex(accounts)).toBe(3); - }); - - it('finds first gap with sparse indices', () => { - const accounts = [ - MOCK_SOLANA_KEYRING_ACCOUNT_0, - MOCK_SOLANA_KEYRING_ACCOUNT_1, - MOCK_SOLANA_KEYRING_ACCOUNT_4, - MOCK_SOLANA_KEYRING_ACCOUNT_5, - ]; - expect(getLowestUnusedIndex(accounts)).toBe(2); - }); -}); diff --git a/merged-packages/tron-wallet-snap/src/utils/i18n.test.ts b/merged-packages/tron-wallet-snap/src/utils/i18n.test.ts deleted file mode 100644 index 05f5c26f..00000000 --- a/merged-packages/tron-wallet-snap/src/utils/i18n.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { i18n, locales } from './i18n'; - -describe('i18n', () => { - it('returns the correct translation for a given key', () => { - const translate = i18n('en'); - const message = translate('send.balance'); - expect(message).toBe(locales.en['send.balance'].message); - }); - - it('returns the correct translation for a given key and replaces', () => { - const translate = i18n('en'); - const message = translate('send.transaction-failure.subtitle', { - amount: '1.23', - tokenSymbol: 'SOL', - }); - expect(message).toBe('Unable to send 1.23 SOL'); - }); - - it('falls back to the default language if the preferred locale is not available', () => { - const translate = i18n('fr' as any); - const message = translate('send.balance'); - expect(message).toBe(locales.en['send.balance'].message); - }); - - it('returns the key for a non-existent key', () => { - const translate = i18n('en'); - const message = translate('nonExistentKey' as any); - expect(message).toBe('nonExistentKey'); - }); -}); diff --git a/merged-packages/tron-wallet-snap/src/utils/logger.ts b/merged-packages/tron-wallet-snap/src/utils/logger.ts index 03886b5a..ebbf3a7f 100644 --- a/merged-packages/tron-wallet-snap/src/utils/logger.ts +++ b/merged-packages/tron-wallet-snap/src/utils/logger.ts @@ -13,7 +13,7 @@ export type ILogger = { debug: (...args: any[]) => void; }; -const withSolanaErrorLogging = +const withErrorLogging = (logFn: (...args: any[]) => void) => (...args: any[]): void => { logFn(...args); @@ -44,7 +44,7 @@ const logger: ILogger = { info: withNoopInProduction(console.info), warn: withNoopInProduction(console.warn), debug: withNoopInProduction(console.debug), - error: withNoopInProduction(withSolanaErrorLogging(console.error)), + error: withNoopInProduction(withErrorLogging(console.error)), }; export default logger; diff --git a/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts b/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts new file mode 100644 index 00000000..d02945d4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts @@ -0,0 +1,9 @@ +import { ILogger } from "./logger"; + +export const mockLogger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +} as unknown as ILogger; diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts index 6d7d3b6c..98112a44 100644 --- a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts @@ -29,7 +29,7 @@ export const deserialize = (serializedValue: Json): Serializable => } if (value.__type === 'Uint8Array') { - return Buffer.from(value.value, 'base64'); // Uses 'base64' encoding + return new Uint8Array(Buffer.from(value.value, 'base64')); } return value; From 066533f6140ac730581f089fa4edda7ebf396da5 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 21 Jul 2025 16:35:18 +0100 Subject: [PATCH 003/238] chore: lint all files --- .../tron-wallet-snap/snap.config.ts | 3 +- .../tron-wallet-snap/snap.manifest.json | 2 +- .../tron-wallet-snap/src/caching/ICache.ts | 10 +++ .../src/caching/InMemoryCache.ts | 6 +- .../src/caching/StateCache.test.ts | 2 +- .../src/caching/StateCache.ts | 8 +- .../src/caching/useCache.test.ts | 2 +- .../tron-wallet-snap/src/caching/useCache.ts | 4 +- .../clients/price-api/PriceApiClient.test.ts | 8 +- .../src/clients/price-api/PriceApiClient.ts | 18 ++--- .../tron-wallet-snap/src/constants/tron.ts | 4 +- .../tron-wallet-snap/src/context.ts | 53 ++++++------- .../tron-wallet-snap/src/handlers/cronjob.ts | 6 +- .../tron-wallet-snap/src/handlers/keyring.ts | 16 ++-- merged-packages/tron-wallet-snap/src/index.ts | 9 ++- .../tron-wallet-snap/src/permissions.ts | 1 - .../src/services/assets/AssetsService.ts | 76 +++++++++++-------- .../src/services/assets/types.ts | 8 +- .../src/services/config/ConfigProvider.ts | 6 +- .../src/services/state/InMemoryState.ts | 2 +- .../src/services/state/State.ts | 4 +- .../transactions/TransactionsService.ts | 19 +++-- .../src/services/wallet/WalletService.ts | 21 +++-- .../tron-wallet-snap/src/utils/mockLogger.ts | 2 +- .../src/utils/serialization/serialize.test.ts | 1 - 25 files changed, 164 insertions(+), 127 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.config.ts b/merged-packages/tron-wallet-snap/snap.config.ts index 1f66a307..94b169c0 100644 --- a/merged-packages/tron-wallet-snap/snap.config.ts +++ b/merged-packages/tron-wallet-snap/snap.config.ts @@ -22,7 +22,8 @@ const config: SnapConfig = { PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL ?? '', TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL ?? '', STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL ?? '', - SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL ?? '', + SECURITY_ALERTS_API_BASE_URL: + process.env.SECURITY_ALERTS_API_BASE_URL ?? '', NFT_API_BASE_URL: process.env.NFT_API_BASE_URL ?? '', LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL ?? '', }, diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index cf7f2660..666568ee 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "5IPCMVax/lf1NsIQge8qz+4FMCjR9GIMhRv+wlqJOAI=", + "shasum": "B5xj0iLe7fG9EAiNFtekof/r31zDWhPeOncy0k0gWyM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/caching/ICache.ts b/merged-packages/tron-wallet-snap/src/caching/ICache.ts index af603856..8a7a71f4 100644 --- a/merged-packages/tron-wallet-snap/src/caching/ICache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/ICache.ts @@ -6,6 +6,7 @@ export type ICache = { /** * Retrieves a value from the cache by key. + * * @param key - The key to retrieve * @returns The value if found, undefined if not found */ @@ -15,6 +16,7 @@ export type ICache = { * Stores a value in the cache with an optional TTL. * - If a value is undefined, it will not be stored in the cache. * - If a value is null, it will be stored in the cache. + * * @param key - The key to store the value under * @param value - The value to store * @param ttlMilliseconds - Optional time-to-live in milliseconds. If not provided, the value will not expire. @@ -24,6 +26,7 @@ export type ICache = { /** * Removes a value from the cache. + * * @param key - The key to remove * @returns true if the key was found and removed, false otherwise */ @@ -36,6 +39,7 @@ export type ICache = { /** * Checks if a key exists in the cache. + * * @param key - The key to check * @returns true if the key exists, false otherwise */ @@ -43,18 +47,21 @@ export type ICache = { /** * Returns all keys currently in the cache. + * * @returns Array of keys */ keys(): Promise; /** * Returns the number of items in the cache. + * * @returns The number of items */ size(): Promise; /** * Retrieves a value from the cache without affecting its TTL or last accessed time. + * * @param key - The key to peek at * @returns The value if found, undefined if not found */ @@ -62,6 +69,7 @@ export type ICache = { /** * Retrieves multiple values from the cache in a single operation. + * * @param keys - Array of keys to retrieve * @returns Object mapping keys to their values (or undefined if not found) */ @@ -71,6 +79,7 @@ export type ICache = { * Stores multiple values in the cache in a single operation. * - If a value is undefined, it will not be stored in the cache. * - If a value is null, it will be stored in the cache. + * * @param entries - Array of entries to store, each with key, value, and optional TTL (if not provided, the value will not expire) * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 */ @@ -80,6 +89,7 @@ export type ICache = { /** * Removes multiple values from the cache. + * * @param keys - Array of keys to remove * @returns An object mapping each key to a boolean indicating whether it was found and removed */ diff --git a/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts b/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts index ba380573..b490e16c 100644 --- a/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts @@ -1,9 +1,9 @@ import { assert } from '@metamask/utils'; -import type { ILogger } from '../utils/logger'; -import type { Serializable } from '../utils/serialization/types'; import type { ICache } from './ICache'; import type { CacheEntry } from './types'; +import type { ILogger } from '../utils/logger'; +import type { Serializable } from '../utils/serialization/types'; /** * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. @@ -12,7 +12,7 @@ import type { CacheEntry } from './types'; * - This cache is not persistent and will be lost when the process is restarted. */ export class InMemoryCache implements ICache { - #cache: Map = new Map(); + readonly #cache: Map = new Map(); public readonly logger: ILogger; diff --git a/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts b/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts index ff6e4022..60d4d702 100644 --- a/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts +++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts @@ -1,7 +1,7 @@ /* eslint-disable jest/prefer-strict-equal */ /* eslint-disable @typescript-eslint/naming-convention */ -import { InMemoryState } from '../services/state/InMemoryState'; import { StateCache } from './StateCache'; +import { InMemoryState } from '../services/state/InMemoryState'; describe('StateCache', () => { describe('constructor', () => { diff --git a/merged-packages/tron-wallet-snap/src/caching/StateCache.ts b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts index 115b5fa5..aa5d3d68 100644 --- a/merged-packages/tron-wallet-snap/src/caching/StateCache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts @@ -1,12 +1,10 @@ -/* eslint-disable @typescript-eslint/naming-convention */ - import { assert } from '@metamask/utils'; +import type { ICache } from './ICache'; +import type { CacheEntry } from './types'; import type { IStateManager } from '../services/state/IStateManager'; import type { ILogger } from '../utils/logger'; import type { Serializable } from '../utils/serialization/types'; -import type { ICache } from './ICache'; -import type { CacheEntry } from './types'; /** * The whole cache store. @@ -72,7 +70,7 @@ export type StateValue = { * ``` */ export class StateCache implements ICache { - #state: IStateManager; + readonly #state: IStateManager; public readonly prefix: CachePrefix; diff --git a/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts b/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts index 3b438d99..ae17158b 100644 --- a/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts +++ b/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts @@ -1,6 +1,6 @@ -import { Serializable } from '../utils/serialization/types'; import type { ICache } from './ICache'; import { useCache, type CacheOptions } from './useCache'; +import type { Serializable } from '../utils/serialization/types'; describe('useCache', () => { // Spy to check if the original function was executed or not diff --git a/merged-packages/tron-wallet-snap/src/caching/useCache.ts b/merged-packages/tron-wallet-snap/src/caching/useCache.ts index a31d798d..bde0f573 100644 --- a/merged-packages/tron-wallet-snap/src/caching/useCache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/useCache.ts @@ -1,8 +1,8 @@ /* eslint-disable no-void */ -/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { ICache } from './ICache'; import logger from '../utils/logger'; import type { Serializable } from '../utils/serialization/types'; -import type { ICache } from './ICache'; /** * Options for configuring the caching behavior of a function. diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts index c862a767..cdfa7fb0 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts @@ -2,16 +2,16 @@ import type { CaipAssetType } from '@metamask/keyring-api'; import { cloneDeep } from 'lodash'; +import { MOCK_EXCHANGE_RATES } from './mocks/exchange-rates'; +import { MOCK_HISTORICAL_PRICES } from './mocks/historical-prices'; +import { PriceApiClient } from './PriceApiClient'; +import type { SpotPrices, VsCurrencyParam } from './types'; import type { ICache } from '../../caching/ICache'; import { InMemoryCache } from '../../caching/InMemoryCache'; import { KnownCaip19Id } from '../../constants/tron'; import type { ConfigProvider } from '../../services/config'; import { mockLogger } from '../../utils/mockLogger'; import type { Serializable } from '../../utils/serialization/types'; -import { MOCK_EXCHANGE_RATES } from './mocks/exchange-rates'; -import { MOCK_HISTORICAL_PRICES } from './mocks/historical-prices'; -import { PriceApiClient } from './PriceApiClient'; -import type { SpotPrices, VsCurrencyParam } from './types'; describe('PriceApiClient', () => { let mockFetch: jest.Mock; diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts index 1a5d6222..bfd69e02 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts @@ -1,18 +1,10 @@ /* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable no-restricted-globals */ + import type { CaipAssetType } from '@metamask/keyring-api'; import { array, assert } from '@metamask/superstruct'; import { CaipAssetTypeStruct } from '@metamask/utils'; import { mapKeys } from 'lodash'; -import type { ICache } from '../../caching/ICache'; -import { useCache } from '../../caching/useCache'; -import type { ConfigProvider } from '../../services/config'; -import { buildUrl } from '../../utils/buildUrl'; -import type { ILogger } from '../../utils/logger'; -import logger from '../../utils/logger'; -import type { Serializable } from '../../utils/serialization/types'; -import { UrlStruct } from '../../validation/structs'; import type { ExchangeRate, FiatTicker, @@ -27,6 +19,14 @@ import { SpotPricesStruct, VsCurrencyParamStruct, } from './types'; +import type { ICache } from '../../caching/ICache'; +import { useCache } from '../../caching/useCache'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import type { Serializable } from '../../utils/serialization/types'; +import { UrlStruct } from '../../validation/structs'; export class PriceApiClient { readonly #fetch: typeof globalThis.fetch; diff --git a/merged-packages/tron-wallet-snap/src/constants/tron.ts b/merged-packages/tron-wallet-snap/src/constants/tron.ts index 6742c55c..c2394acf 100644 --- a/merged-packages/tron-wallet-snap/src/constants/tron.ts +++ b/merged-packages/tron-wallet-snap/src/constants/tron.ts @@ -1,4 +1,4 @@ -import { TrxScope } from "@metamask/keyring-api"; +import { TrxScope } from '@metamask/keyring-api'; export enum Network { Mainnet = TrxScope.Mainnet, @@ -64,4 +64,4 @@ export const Networks = { name: 'Tron Localnet', nativeToken: TokenMetadata[KnownCaip19Id.TrxLocalnet], }, -} as const; \ No newline at end of file +} as const; diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 93d962d3..997b0459 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -1,14 +1,15 @@ -import { AssetsHandler } from "./handlers/assets"; -import { CronHandler } from "./handlers/cronjob"; -import { KeyringHandler } from "./handlers/keyring"; -import { RpcHandler } from "./handlers/rpc"; -import { UserInputHandler } from "./handlers/userInput"; -import { AssetsService } from "./services/assets/AssetsService"; -import { ConfigProvider } from "./services/config"; -import { State, UnencryptedStateValue } from "./services/state/State"; -import { TransactionsService } from "./services/transactions/TransactionsService"; -import { WalletService } from "./services/wallet/WalletService"; -import logger from "./utils/logger"; +import { AssetsHandler } from './handlers/assets'; +import { CronHandler } from './handlers/cronjob'; +import { KeyringHandler } from './handlers/keyring'; +import { RpcHandler } from './handlers/rpc'; +import { UserInputHandler } from './handlers/userInput'; +import { AssetsService } from './services/assets/AssetsService'; +import { ConfigProvider } from './services/config'; +import type { UnencryptedStateValue } from './services/state/State'; +import { State } from './services/state/State'; +import { TransactionsService } from './services/transactions/TransactionsService'; +import { WalletService } from './services/wallet/WalletService'; +import logger from './utils/logger'; /** * Services @@ -26,19 +27,19 @@ const state = new State({ }); const assetsService = new AssetsService({ - logger: logger, - configProvider: configProvider, - state: state, + logger, + configProvider, + state, }); const transactionService = new TransactionsService({ - logger: logger, - state: state, + logger, + state, }); const walletService = new WalletService({ - logger: logger, - state: state, + logger, + state, }); /** @@ -47,11 +48,11 @@ const walletService = new WalletService({ const assetsHandler = new AssetsHandler(); const cronHandler = new CronHandler(); const keyringHandler = new KeyringHandler({ - logger: logger, - state: state, - assetsService: assetsService, - transactionService: transactionService, - walletService: walletService, + logger, + state, + assetsService, + transactionService, + walletService, }); const rpcHandler = new RpcHandler(); const userInputHandler = new UserInputHandler(); @@ -72,7 +73,7 @@ export type SnapExecutionContext = { keyringHandler: KeyringHandler; rpcHandler: RpcHandler; userInputHandler: UserInputHandler; -} +}; const snapContext: SnapExecutionContext = { /** @@ -89,7 +90,7 @@ const snapContext: SnapExecutionContext = { cronHandler, keyringHandler, rpcHandler, - userInputHandler + userInputHandler, }; export { @@ -100,7 +101,7 @@ export { cronHandler, keyringHandler, rpcHandler, - userInputHandler + userInputHandler, }; export default snapContext; diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts index 25e3e655..5df88a5b 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts @@ -1,11 +1,7 @@ import type { JsonRpcRequest } from '@metamask/utils'; export class CronHandler { - async handle({ - request, - }: { - request: JsonRpcRequest; - }): Promise { + async handle({ request }: { request: JsonRpcRequest }): Promise { /** * Map cronjob to the appropriate handler */ diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index 592cccab..d73c5293 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -21,6 +21,7 @@ import { emitSnapKeyringEvent, handleKeyringRequest, } from '@metamask/keyring-snap-sdk'; +import { SnapError } from '@metamask/snaps-sdk'; import { assert, integer } from '@metamask/superstruct'; import type { CaipAssetType, @@ -29,15 +30,14 @@ import type { Json, JsonRpcRequest, } from '@metamask/utils'; - -import { SnapError } from '@metamask/snaps-sdk'; import { sortBy } from 'lodash'; + import type { TronKeyringAccount } from '../entities'; import { asStrictKeyringAccount } from '../entities/keyring-account'; -import { AssetsService } from '../services/assets/AssetsService'; -import { State, UnencryptedStateValue } from '../services/state/State'; -import { TransactionsService } from '../services/transactions/TransactionsService'; -import { WalletService } from '../services/wallet/WalletService'; +import type { AssetsService } from '../services/assets/AssetsService'; +import type { State, UnencryptedStateValue } from '../services/state/State'; +import type { TransactionsService } from '../services/transactions/TransactionsService'; +import type { WalletService } from '../services/wallet/WalletService'; import { deriveTronKeypair } from '../utils/deriveTronKeypair'; import { withCatchAndThrowSnapError } from '../utils/errors'; import { getLowestUnusedIndex } from '../utils/getLowestUnusedIndex'; @@ -92,9 +92,7 @@ export class KeyringHandler implements Keyring { } } - async getAccount( - accountId: string, - ): Promise { + async getAccount(accountId: string): Promise { try { const account = await this.#state.getKey( `keyringAccounts.${accountId}`, diff --git a/merged-packages/tron-wallet-snap/src/index.ts b/merged-packages/tron-wallet-snap/src/index.ts index c25815ac..b2a4b009 100644 --- a/merged-packages/tron-wallet-snap/src/index.ts +++ b/merged-packages/tron-wallet-snap/src/index.ts @@ -8,7 +8,14 @@ import type { OnRpcRequestHandler, OnUserInputHandler, } from '@metamask/snaps-sdk'; -import { assetsHandler, cronHandler, keyringHandler, rpcHandler, userInputHandler } from './context'; + +import { + assetsHandler, + cronHandler, + keyringHandler, + rpcHandler, + userInputHandler, +} from './context'; /** * Register all handlers diff --git a/merged-packages/tron-wallet-snap/src/permissions.ts b/merged-packages/tron-wallet-snap/src/permissions.ts index 8b01fc12..dc50f409 100644 --- a/merged-packages/tron-wallet-snap/src/permissions.ts +++ b/merged-packages/tron-wallet-snap/src/permissions.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/only-throw-error */ import { KeyringRpcMethod } from '@metamask/keyring-api'; const prodOrigins = ['https://portfolio.metamask.io']; diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index cb689bfb..8e3dc31b 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,11 +1,21 @@ -import { AssetMetadata, FungibleAssetMetadata, NonFungibleAssetMetadata } from "@metamask/snaps-sdk"; -import { CaipAssetType, parseCaipAssetType } from "@metamask/utils"; -import { uniq } from "lodash"; -import { TronKeyringAccount } from "../../entities"; -import { ILogger } from "../../utils/logger"; -import { ConfigProvider } from "../config"; -import { State, UnencryptedStateValue } from "../state/State"; -import { NativeCaipAssetType, NftCaipAssetType, TokenCaipAssetType } from "./types"; +import type { + AssetMetadata, + FungibleAssetMetadata, + NonFungibleAssetMetadata, +} from '@metamask/snaps-sdk'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; +import { uniq } from 'lodash'; + +import type { TronKeyringAccount } from '../../entities'; +import type { ILogger } from '../../utils/logger'; +import type { ConfigProvider } from '../config'; +import type { + NativeCaipAssetType, + NftCaipAssetType, + TokenCaipAssetType, +} from './types'; +import type { State, UnencryptedStateValue } from '../state/State'; export class AssetsService { readonly #logger: ILogger; @@ -13,16 +23,26 @@ export class AssetsService { readonly #loggerPrefix = '[🪙 AssetsService]'; readonly #configProvider: ConfigProvider; - + readonly #state: State; - constructor({ logger, configProvider, state }: { logger: ILogger, configProvider: ConfigProvider, state: State }) { + constructor({ + logger, + configProvider, + state, + }: { + logger: ILogger; + configProvider: ConfigProvider; + state: State; + }) { this.#logger = logger; this.#configProvider = configProvider; this.#state = state; } - async #listAddressNativeAssets(address: string): Promise { + async #listAddressNativeAssets( + address: string, + ): Promise { return []; // TODO: Implement me } @@ -45,23 +65,15 @@ export class AssetsService { account, ); - const accountAddress = account.address + const accountAddress = account.address; - const [ - nativeAssetsIds, - tokenAssetsIds, - nftAssetsIds - ] = await Promise.all([ + const [nativeAssetsIds, tokenAssetsIds, nftAssetsIds] = await Promise.all([ this.#listAddressNativeAssets(accountAddress), this.#listAddressTokenAssets(accountAddress), this.#listAddressNftAssets(accountAddress), ]); - return uniq([ - ...nativeAssetsIds, - ...tokenAssetsIds, - ...nftAssetsIds - ]); + return uniq([...nativeAssetsIds, ...tokenAssetsIds, ...nftAssetsIds]); } async getAssetsMetadata( @@ -76,15 +88,12 @@ export class AssetsService { const { nativeAssetTypes, tokenAssetTypes, nftAssetTypes } = this.#splitAssetsByType(assetTypes); - const [ - nativeTokensMetadata, - tokensMetadata, - nftMetadata, - ] = await Promise.all([ - this.getNativeTokensMetadata(nativeAssetTypes), - this.getTokensMetadata(tokenAssetTypes), - this.getNftsMetadata(nftAssetTypes), - ]); + const [nativeTokensMetadata, tokensMetadata, nftMetadata] = + await Promise.all([ + this.getNativeTokensMetadata(nativeAssetTypes), + this.getTokensMetadata(tokenAssetTypes), + this.getNftsMetadata(nftAssetTypes), + ]); return { ...nativeTokensMetadata, @@ -97,8 +106,9 @@ export class AssetsService { const nativeAssetTypes = assetTypes.filter((assetType) => assetType.endsWith('slip44:195'), ) as NativeCaipAssetType[]; - const tokenAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc10:') || assetType.includes('/trc20:'), + const tokenAssetTypes = assetTypes.filter( + (assetType) => + assetType.includes('/trc10:') || assetType.includes('/trc20:'), ) as TokenCaipAssetType[]; const nftAssetTypes = assetTypes.filter((assetType) => assetType.includes('/trc721'), diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts index a55f3407..3f6987df 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -1,11 +1,11 @@ -import { TrxScope } from "@metamask/keyring-api"; -import { pattern, string } from "@metamask/superstruct"; +import type { TrxScope } from '@metamask/keyring-api'; +import { pattern, string } from '@metamask/superstruct'; export type NativeCaipAssetType = `${TrxScope}/slip44:195`; export type TokenCaipAssetType = `${TrxScope}/${'trc10' | 'trc20'}:${string}`; export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; - /** +/** * Validates a TRON native CAIP-19 ID (e.g., "tron:mainnet/slip44:195") */ export const NativeCaipAssetTypeStruct = pattern( @@ -27,4 +27,4 @@ export const TokenCaipAssetTypeStruct = pattern( export const NftCaipAssetTypeStruct = pattern( string(), /^tron:(mainnet|nile|shasta)\/trc721:[a-zA-Z0-9]+$/u, -); \ No newline at end of file +); diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index ab3d5c7a..600e7f4d 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -6,7 +6,7 @@ import { create, enums, object, - string + string, } from '@metamask/superstruct'; import { Duration } from '@metamask/utils'; @@ -99,7 +99,7 @@ export type Config = { * const network = configProvider.getNetworkBy('caip2Id', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); */ export class ConfigProvider { - #config: Config; + readonly #config: Config; constructor() { const environment = this.#parseEnvironment(); @@ -111,7 +111,7 @@ export class ConfigProvider { ENVIRONMENT: process.env.ENVIRONMENT, // RPC RPC_URL_LIST_MAINNET: process.env.RPC_URL_LIST_MAINNET, - RPC_URL_LIST_NILE_TESTNET: process.env.RPC_URL_NILE_LIST_TESTNET, + RPC_URL_LIST_NILE_TESTNET: process.env.RPC_URL_LIST_NILE_TESTNET, RPC_URL_LIST_SHASTA_TESTNET: process.env.RPC_URL_LIST_SHASTA_TESTNET, RPC_URL_LIST_LOCALNET: process.env.RPC_URL_LIST_LOCALNET, // Block explorer diff --git a/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts b/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts index 06c176e5..dcb98a7f 100644 --- a/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts +++ b/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts @@ -1,7 +1,7 @@ import { get, set, unset } from 'lodash'; -import type { Serializable } from '../../utils/serialization/types'; import type { IStateManager } from './IStateManager'; +import type { Serializable } from '../../utils/serialization/types'; /** * A simple implementation of the `IStateManager` interface that relies on an in memory state that can be used for testing purposes. diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.ts b/merged-packages/tron-wallet-snap/src/services/state/State.ts index b68937fa..a74a0789 100644 --- a/merged-packages/tron-wallet-snap/src/services/state/State.ts +++ b/merged-packages/tron-wallet-snap/src/services/state/State.ts @@ -2,13 +2,13 @@ import type { Balance, Transaction } from '@metamask/keyring-api'; import type { CaipAssetType } from '@metamask/utils'; import { unset } from 'lodash'; +import type { IStateManager } from './IStateManager'; import type { SpotPrices } from '../../clients/price-api/types'; -import { TronKeyringAccount } from '../../entities'; +import type { TronKeyringAccount } from '../../entities'; import { safeMerge } from '../../utils/safeMerge'; import { deserialize } from '../../utils/serialization/deserialize'; import { serialize } from '../../utils/serialization/serialize'; import type { Serializable } from '../../utils/serialization/types'; -import type { IStateManager } from './IStateManager'; export type AccountId = string; diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts index 769fc398..3a71bf90 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts @@ -1,7 +1,8 @@ -import { Transaction } from "@metamask/keyring-api"; -import { TronKeyringAccount } from "../../entities"; -import { ILogger } from "../../utils/logger"; -import { State, UnencryptedStateValue } from "../state/State"; +import type { Transaction } from '@metamask/keyring-api'; + +import type { TronKeyringAccount } from '../../entities'; +import type { ILogger } from '../../utils/logger'; +import type { State, UnencryptedStateValue } from '../state/State'; export class TransactionsService { readonly #logger: ILogger; @@ -10,7 +11,13 @@ export class TransactionsService { readonly #state: State; - constructor({ logger, state }: { logger: ILogger, state: State }) { + constructor({ + logger, + state, + }: { + logger: ILogger; + state: State; + }) { this.#logger = logger; this.#state = state; } @@ -18,4 +25,4 @@ export class TransactionsService { async listTransactions(account: TronKeyringAccount): Promise { return []; // TODO: Implement me } -} \ No newline at end of file +} diff --git a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts index d284da22..ae52717c 100644 --- a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts +++ b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts @@ -1,5 +1,5 @@ -import { ILogger } from "../../utils/logger"; -import { State, UnencryptedStateValue } from "../state/State"; +import type { ILogger } from '../../utils/logger'; +import type { State, UnencryptedStateValue } from '../state/State'; export class WalletService { readonly #logger: ILogger; @@ -8,7 +8,13 @@ export class WalletService { readonly #state: State; - constructor({ logger, state }: { logger: ILogger, state: State }) { + constructor({ + logger, + state, + }: { + logger: ILogger; + state: State; + }) { this.#logger = logger; this.#state = state; } @@ -19,7 +25,12 @@ export class WalletService { } async verifyMessage(message: string, signature: string): Promise { - this.#logger.log(this.#loggerPrefix, 'Verifying message...', message, signature); + this.#logger.log( + this.#loggerPrefix, + 'Verifying message...', + message, + signature, + ); return true; // TODO: Implement me } -} \ No newline at end of file +} diff --git a/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts b/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts index d02945d4..17c513cf 100644 --- a/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts +++ b/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts @@ -1,4 +1,4 @@ -import { ILogger } from "./logger"; +import type { ILogger } from './logger'; export const mockLogger = { log: jest.fn(), diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts index d6a2103d..8d30ec8d 100644 --- a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable jest/prefer-strict-equal */ /* eslint-disable @typescript-eslint/naming-convention */ import BigNumber from 'bignumber.js'; From c772ce39a3421f117771430b49279b790b3f7f90 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 22 Jul 2025 11:32:55 +0100 Subject: [PATCH 004/238] chore: more improvements to the test dApp --- merged-packages/tron-wallet-snap/.env.example | 4 +- merged-packages/tron-wallet-snap/package.json | 1 + .../tron-wallet-snap/snap.config.ts | 4 +- .../tron-wallet-snap/snap.manifest.json | 2 +- .../tron-wallet-snap/src/caching/useCache.ts | 2 +- .../clients/price-api/PriceApiClient.test.ts | 6 +-- .../src/constants/{tron.ts => index.ts} | 0 .../tron-wallet-snap/src/context.ts | 2 +- .../tron-wallet-snap/src/handlers/rpc.ts | 6 +++ .../src/services/config/ConfigProvider.ts | 17 +++++-- .../src/utils/getExplorerUrl.test.ts | 48 +++++++++++++++++++ .../src/utils/getExplorerUrl.ts | 41 ++++++++++++++++ .../src/validation/structs.ts | 2 +- 13 files changed, 121 insertions(+), 14 deletions(-) rename merged-packages/tron-wallet-snap/src/constants/{tron.ts => index.ts} (100%) create mode 100644 merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts diff --git a/merged-packages/tron-wallet-snap/.env.example b/merged-packages/tron-wallet-snap/.env.example index 769dd70d..6f746f05 100644 --- a/merged-packages/tron-wallet-snap/.env.example +++ b/merged-packages/tron-wallet-snap/.env.example @@ -9,7 +9,9 @@ RPC_URL_LIST_NILE_TESTNET=https://nile.trongrid.io RPC_URL_LIST_SHASTA_TESTNET=https://api.shasta.trongrid.io/jsonrpc RPC_URL_LIST_LOCALNET=http://localhost:8899 -EXPLORER_BASE_URL=https://tronscan.org +EXPLORER_MAINNET_BASE_URL=https://tronscan.org +EXPLORER_NILE_BASE_URL=https://nile.tronscan.org +EXPLORER_SHASTA_BASE_URL=https://shasta.tronscan.org PRICE_API_BASE_URL=https://price.api.cx.metamask.io TOKEN_API_BASE_URL=https://tokens.api.cx.metamask.io diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 6e317cb8..75a57715 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -54,6 +54,7 @@ "jest-mock-extended": "^4.0.0", "jest-transform-stub": "2.0.0", "lodash": "^4.17.21", + "prettier": "^3.5.3", "superstruct": "^2.0.2", "ts-jest": "^29.4.0", "uuid": "^11.1.0" diff --git a/merged-packages/tron-wallet-snap/snap.config.ts b/merged-packages/tron-wallet-snap/snap.config.ts index 94b169c0..4ef59311 100644 --- a/merged-packages/tron-wallet-snap/snap.config.ts +++ b/merged-packages/tron-wallet-snap/snap.config.ts @@ -17,7 +17,9 @@ const config: SnapConfig = { RPC_URL_LIST_SHASTA_TESTNET: process.env.RPC_URL_LIST_SHASTA_TESTNET ?? '', RPC_URL_LIST_LOCALNET: process.env.RPC_URL_LIST_LOCALNET ?? '', // Block explorer - EXPLORER_BASE_URL: process.env.EXPLORER_BASE_URL ?? '', + EXPLORER_MAINNET_BASE_URL: process.env.EXPLORER_MAINNET_BASE_URL ?? '', + EXPLORER_NILE_BASE_URL: process.env.EXPLORER_NILE_BASE_URL ?? '', + EXPLORER_SHASTA_BASE_URL: process.env.EXPLORER_SHASTA_BASE_URL ?? '', // APIs PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL ?? '', TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL ?? '', diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 666568ee..6f4e1470 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "B5xj0iLe7fG9EAiNFtekof/r31zDWhPeOncy0k0gWyM=", + "shasum": "AVFO+sFMDnswDDXG0W+VH81FxLbR+xAqU+chY+mJ6Nk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/caching/useCache.ts b/merged-packages/tron-wallet-snap/src/caching/useCache.ts index bde0f573..7f622c7b 100644 --- a/merged-packages/tron-wallet-snap/src/caching/useCache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/useCache.ts @@ -30,7 +30,7 @@ export type CacheOptions = { * @param args - The arguments of the function call. * @returns The cache key. */ -const defaultGenerateCacheKey = (functionName: string, args: any[]) => +const defaultGenerateCacheKey = (functionName: string, args: any[]): string => `${functionName}:${args.map((arg) => JSON.stringify(arg)).join(':')}`; /** diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts index cdfa7fb0..b0c21bbf 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts @@ -3,12 +3,12 @@ import type { CaipAssetType } from '@metamask/keyring-api'; import { cloneDeep } from 'lodash'; import { MOCK_EXCHANGE_RATES } from './mocks/exchange-rates'; +import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; +import { KnownCaip19Id } from '../../constants'; import { MOCK_HISTORICAL_PRICES } from './mocks/historical-prices'; import { PriceApiClient } from './PriceApiClient'; import type { SpotPrices, VsCurrencyParam } from './types'; -import type { ICache } from '../../caching/ICache'; -import { InMemoryCache } from '../../caching/InMemoryCache'; -import { KnownCaip19Id } from '../../constants/tron'; import type { ConfigProvider } from '../../services/config'; import { mockLogger } from '../../utils/mockLogger'; import type { Serializable } from '../../utils/serialization/types'; diff --git a/merged-packages/tron-wallet-snap/src/constants/tron.ts b/merged-packages/tron-wallet-snap/src/constants/index.ts similarity index 100% rename from merged-packages/tron-wallet-snap/src/constants/tron.ts rename to merged-packages/tron-wallet-snap/src/constants/index.ts diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 997b0459..5843b424 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -14,7 +14,7 @@ import logger from './utils/logger'; /** * Services */ -const configProvider = new ConfigProvider(); +export const configProvider = new ConfigProvider(); const state = new State({ encrypted: false, diff --git a/merged-packages/tron-wallet-snap/src/handlers/rpc.ts b/merged-packages/tron-wallet-snap/src/handlers/rpc.ts index 2079e581..aeac9718 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/rpc.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/rpc.ts @@ -2,6 +2,12 @@ import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import { validateOrigin } from '../validation/validators'; +export enum RpcRequestMethods { + OnStart = 'onStart', + OnInstall = 'onInstall', + OnUpdate = 'onUpdate', +} + export class RpcHandler { async handle( origin: string, diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index 600e7f4d..5f650850 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -10,7 +10,7 @@ import { } from '@metamask/superstruct'; import { Duration } from '@metamask/utils'; -import { Network, Networks } from '../../constants/tron'; +import { Network, Networks } from '../../constants'; import { UrlStruct } from '../../validation/structs'; const ENVIRONMENT_TO_ACTIVE_NETWORKS = { @@ -37,7 +37,9 @@ const EnvStruct = object({ RPC_URL_LIST_NILE_TESTNET: CommaSeparatedListOfUrlsStruct, RPC_URL_LIST_SHASTA_TESTNET: CommaSeparatedListOfUrlsStruct, RPC_URL_LIST_LOCALNET: CommaSeparatedListOfStringsStruct, - EXPLORER_BASE_URL: UrlStruct, + EXPLORER_MAINNET_BASE_URL: UrlStruct, + EXPLORER_NILE_BASE_URL: UrlStruct, + EXPLORER_SHASTA_BASE_URL: UrlStruct, PRICE_API_BASE_URL: UrlStruct, TOKEN_API_BASE_URL: UrlStruct, STATIC_API_BASE_URL: UrlStruct, @@ -50,13 +52,13 @@ export type Env = Infer; export type NetworkConfig = (typeof Networks)[Network] & { rpcUrls: string[]; + explorerBaseUrl: string; }; export type Config = { environment: string; networks: NetworkConfig[]; activeNetworks: Network[]; - explorerBaseUrl: string; priceApi: { baseUrl: string; chunkSize: number; @@ -115,7 +117,9 @@ export class ConfigProvider { RPC_URL_LIST_SHASTA_TESTNET: process.env.RPC_URL_LIST_SHASTA_TESTNET, RPC_URL_LIST_LOCALNET: process.env.RPC_URL_LIST_LOCALNET, // Block explorer - EXPLORER_BASE_URL: process.env.EXPLORER_BASE_URL, + EXPLORER_MAINNET_BASE_URL: process.env.EXPLORER_MAINNET_BASE_URL, + EXPLORER_NILE_BASE_URL: process.env.EXPLORER_NILE_BASE_URL, + EXPLORER_SHASTA_BASE_URL: process.env.EXPLORER_SHASTA_BASE_URL, // APIs PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL, TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL, @@ -136,21 +140,24 @@ export class ConfigProvider { { ...Networks[Network.Mainnet], rpcUrls: environment.RPC_URL_LIST_MAINNET, + explorerBaseUrl: environment.EXPLORER_MAINNET_BASE_URL, }, { ...Networks[Network.Nile], rpcUrls: environment.RPC_URL_LIST_NILE_TESTNET, + explorerBaseUrl: environment.EXPLORER_NILE_BASE_URL, }, { ...Networks[Network.Shasta], rpcUrls: environment.RPC_URL_LIST_SHASTA_TESTNET, + explorerBaseUrl: environment.EXPLORER_SHASTA_BASE_URL, }, { ...Networks[Network.Localnet], rpcUrls: environment.RPC_URL_LIST_LOCALNET, + explorerBaseUrl: environment.EXPLORER_MAINNET_BASE_URL, }, ], - explorerBaseUrl: environment.EXPLORER_BASE_URL, activeNetworks: ENVIRONMENT_TO_ACTIVE_NETWORKS[environment.ENVIRONMENT], priceApi: { baseUrl: diff --git a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts new file mode 100644 index 00000000..196d06af --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts @@ -0,0 +1,48 @@ +import { Network } from '../constants'; +import { getExplorerUrl } from './getExplorerUrl'; + +describe('getExplorerUrl', () => { + // Set up environment variables for testing + beforeAll(() => { + // eslint-disable-next-line no-restricted-globals + process.env.EXPLORER_MAINNET_BASE_URL = 'https://tronscan.org'; + // eslint-disable-next-line no-restricted-globals + process.env.EXPLORER_NILE_BASE_URL = 'https://tronscan.org'; + // eslint-disable-next-line no-restricted-globals + process.env.EXPLORER_SHASTA_BASE_URL = 'https://tronscan.org'; + }); + + const mockAddress = 'TJRabPrwbZy45savqYt9XgVjvjQvQnQqQq'; + const mockTx = + '4RPWUVqAqW6jHbVuZH5qJuvoJM6EeX9m9Q6PC1RkcYBW3J4zY9LuZPZqNiYNXGm5qL6GJgCB7JqhXqV8vkKxnAHd'; + + it('should generate mainnet URL for address without cluster param', () => { + const url = getExplorerUrl(Network.Mainnet, 'address', mockAddress); + expect(url).toBe(`https://tronscan.org/#/address/${mockAddress}`); + }); + + it('should generate nile URL for address with cluster param', () => { + const url = getExplorerUrl(Network.Nile, 'address', mockAddress); + expect(url).toBe(`https://tronscan.org/#/address/${mockAddress}`); + }); + + it('should generate shasta URL for address with cluster param', () => { + const url = getExplorerUrl(Network.Shasta, 'address', mockAddress); + expect(url).toBe(`https://tronscan.org/#/address/${mockAddress}`); + }); + + it('should generate mainnet URL for transaction without cluster param', () => { + const url = getExplorerUrl(Network.Mainnet, 'transaction', mockTx); + expect(url).toBe(`https://tronscan.org/#/transaction/${mockTx}`); + }); + + it('should generate nile URL for transaction with cluster param', () => { + const url = getExplorerUrl(Network.Nile, 'transaction', mockTx); + expect(url).toBe(`https://tronscan.org/#/transaction/${mockTx}`); + }); + + it('should generate shasta URL for transaction with cluster param', () => { + const url = getExplorerUrl(Network.Shasta, 'transaction', mockTx); + expect(url).toBe(`https://tronscan.org/#/transaction/${mockTx}`); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts new file mode 100644 index 00000000..417c2f76 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts @@ -0,0 +1,41 @@ +import { Network } from '../constants'; +import { buildUrl } from './buildUrl'; + +/** + * Get the Solana Explorer URL for a given scope, type, and value. + * + * @param scope - The scope of the Solana network. + * @param type - The type of the value to explore. + * @param value - The value to explore. + * @returns The Solana Explorer URL. + */ +export function getExplorerUrl( + scope: Network, + type: 'address' | 'transaction', + value: string, +) { + console.log( + 'process.env.EXPLORER_MAINNET_BASE_URL', + process.env.EXPLORER_MAINNET_BASE_URL, + ); + + const NETWORK_TO_EXPLORER_PATH = { + [Network.Mainnet]: process.env.EXPLORER_MAINNET_BASE_URL as string, + [Network.Nile]: process.env.EXPLORER_NILE_BASE_URL as string, + [Network.Shasta]: process.env.EXPLORER_SHASTA_BASE_URL as string, + [Network.Localnet]: process.env.EXPLORER_MAINNET_BASE_URL as string, + }; + + console.log('NETWORK_TO_EXPLORER_PATH', NETWORK_TO_EXPLORER_PATH); + + const baseUrl = NETWORK_TO_EXPLORER_PATH[scope]; + + console.log('baseUrl', baseUrl); + + const url = buildUrl({ + baseUrl, + path: `/#/${type}/${value}`, + }); + + return url; +} diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.ts b/merged-packages/tron-wallet-snap/src/validation/structs.ts index 9617a8d4..c245908e 100644 --- a/merged-packages/tron-wallet-snap/src/validation/structs.ts +++ b/merged-packages/tron-wallet-snap/src/validation/structs.ts @@ -14,7 +14,7 @@ import { string, } from '@metamask/superstruct'; -import { Network } from '../constants/tron'; +import { Network } from '../constants'; // create a uuid validation export const UuidStruct = pattern( From 128386646c77ed9e02a031317cdbf15b1a9bb88e Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Wed, 23 Jul 2025 11:03:11 +0100 Subject: [PATCH 005/238] chore: resolve linting problems --- .../jest.integration.config.js | 1 - .../tron-wallet-snap/messages.json | 2 +- merged-packages/tron-wallet-snap/package.json | 7 +----- .../scripts/build-preinstalled-snap.js | 1 - .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/caching/StateCache.ts | 3 ++- .../clients/price-api/PriceApiClient.test.ts | 6 ++--- .../src/clients/price-api/PriceApiClient.ts | 16 ++++++++------ .../tron-wallet-snap/src/context.ts | 5 +---- .../tron-wallet-snap/src/handlers/assets.ts | 8 +++---- .../tron-wallet-snap/src/handlers/cronjob.ts | 6 ++++- .../tron-wallet-snap/src/handlers/keyring.ts | 2 +- .../src/handlers/userInput.ts | 6 ++--- .../src/services/assets/AssetsService.ts | 22 ++++++++++--------- .../src/services/config/ConfigProvider.ts | 2 +- .../src/services/state/State.test.ts | 2 +- .../transactions/TransactionsService.ts | 21 +----------------- .../src/services/wallet/WalletService.ts | 6 ++--- .../tron-wallet-snap/src/utils/buildUrl.ts | 6 ++--- .../src/utils/getExplorerUrl.test.ts | 12 +++++----- .../src/utils/getExplorerUrl.ts | 18 ++++++--------- .../src/utils/getLowestUnusedIndex.ts | 4 +++- .../tron-wallet-snap/src/utils/safeMerge.ts | 2 +- .../utils/serialization/deserialize.test.ts | 2 +- .../src/utils/serialization/deserialize.ts | 10 +++++++-- .../src/utils/serialization/serialize.test.ts | 2 +- .../src/utils/serialization/serialize.ts | 9 ++++++-- .../src/utils/serialization/types.ts | 1 - .../src/validation/structs.ts | 8 +++---- .../src/validation/validators.ts | 10 ++++----- 30 files changed, 94 insertions(+), 108 deletions(-) diff --git a/merged-packages/tron-wallet-snap/jest.integration.config.js b/merged-packages/tron-wallet-snap/jest.integration.config.js index 4c82e226..c36c839e 100644 --- a/merged-packages/tron-wallet-snap/jest.integration.config.js +++ b/merged-packages/tron-wallet-snap/jest.integration.config.js @@ -8,4 +8,3 @@ const config = { }; export default config; - diff --git a/merged-packages/tron-wallet-snap/messages.json b/merged-packages/tron-wallet-snap/messages.json index cba2b7f2..22b29caa 100644 --- a/merged-packages/tron-wallet-snap/messages.json +++ b/merged-packages/tron-wallet-snap/messages.json @@ -143,4 +143,4 @@ "unexpected": { "message": "An unexpected error occurred" } -} \ No newline at end of file +} diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 75a57715..35c9ded1 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -35,11 +35,9 @@ "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { - "@jest/globals": "^30.0.3", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "patch:@metamask/keyring-api@npm%3A18.0.0#~/.yarn/patches/@metamask-keyring-api-npm-18.0.0-1987f8da30.patch", "@metamask/keyring-snap-sdk": "^4.0.0", - "@metamask/slip44": "^4.2.0", "@metamask/snaps-cli": "^8.1.0", "@metamask/snaps-jest": "^9.2.0", "@metamask/snaps-sdk": "^9.2.0", @@ -51,13 +49,10 @@ "concurrently": "^9.2.0", "dotenv": "^17.0.0", "jest": "^30.0.3", - "jest-mock-extended": "^4.0.0", "jest-transform-stub": "2.0.0", "lodash": "^4.17.21", "prettier": "^3.5.3", - "superstruct": "^2.0.2", - "ts-jest": "^29.4.0", - "uuid": "^11.1.0" + "ts-jest": "^29.4.0" }, "publishConfig": { "access": "public", diff --git a/merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js index 41f461a9..cb4517eb 100644 --- a/merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js +++ b/merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js @@ -1,5 +1,4 @@ // @ts-check - const { readFileSync, writeFileSync } = require('node:fs'); const { join } = require('node:path'); diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 6f4e1470..b2efeaca 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "AVFO+sFMDnswDDXG0W+VH81FxLbR+xAqU+chY+mJ6Nk=", + "shasum": "DB/nbQ3RZwbABP/PC9ZJL3naC7ztEQRk/K7JEB68riM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/caching/StateCache.ts b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts index aa5d3d68..75f7944c 100644 --- a/merged-packages/tron-wallet-snap/src/caching/StateCache.ts +++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts @@ -173,7 +173,8 @@ export class StateCache implements ICache { ); const expiredKeys = keysAndValues.filter( - ([_, cacheEntry]) => cacheEntry && cacheEntry.expiresAt < Date.now(), + ([_unused, cacheEntry]) => + cacheEntry && cacheEntry.expiresAt < Date.now(), ); await this.mdelete(expiredKeys.map(([key]) => key)); diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts index b0c21bbf..87ae7803 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts @@ -66,14 +66,14 @@ describe('PriceApiClient', () => { [KnownCaip19Id.TrxMainnet]: { id: 'tron', price: 0.123456789, - marketCap: 1234567890.123456789, + marketCap: 1234567890.123456, allTimeHigh: 0.5, allTimeLow: 0.01, - totalVolume: 123456789.123456789, + totalVolume: 123456789.123456, high1d: 0.13, low1d: 0.12, circulatingSupply: 1000000000, - dilutedMarketCap: 1234567890.123456789, + dilutedMarketCap: 1234567890.123456, marketCapPercentChange1d: 1.5, priceChange1d: 0.01, pricePercentChange1h: 0.5, diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts index bfd69e02..f4ec5ea2 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts @@ -107,11 +107,13 @@ export class PriceApiClient { // Split uniqueTokenCaipAssetTypes into chunks const chunks: CaipAssetType[][] = []; for ( - let i = 0; - i < uniqueTokenCaipAssetTypes.length; - i += this.#chunkSize + let index = 0; + index < uniqueTokenCaipAssetTypes.length; + index += this.#chunkSize ) { - chunks.push(uniqueTokenCaipAssetTypes.slice(i, i + this.#chunkSize)); + chunks.push( + uniqueTokenCaipAssetTypes.slice(index, index + this.#chunkSize), + ); } // Make parallel requests for each chunk @@ -181,11 +183,11 @@ export class PriceApiClient { const cacheKeyPrefix = 'PriceApiClient:getMultipleSpotPrices'; // Shorthand method to generate the cache key - const toCacheKey = (tokenCaipAssetType: CaipAssetType) => + const toCacheKey = (tokenCaipAssetType: CaipAssetType): string => `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`; // Parses back the cache key - const parseCacheKey = (key: string) => { + const parseCacheKey = (key: string): RegExpMatchArray => { const regex = new RegExp(`^${cacheKeyPrefix}:(.+):(.+)$`, 'u'); const match = key.match(regex); @@ -204,7 +206,7 @@ export class PriceApiClient { // Keys when read from the cache are the cache keys ("PriceApiClient:getMultipleSpotPrices:..."), not the token CAIP-19 IDs, so here we transform them back to the token CAIP-19 types. const cachedSpotPricesRecordWithParsedKeys = mapKeys( cachedSpotPricesRecord, - (_, key) => parseCacheKey(key)[1], + (_unused, key) => parseCacheKey(key)[1], ); // We still need to fetch the spot prices for the tokens that are not cached diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 5843b424..4f2dc3a6 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -32,10 +32,7 @@ const assetsService = new AssetsService({ state, }); -const transactionService = new TransactionsService({ - logger, - state, -}); +const transactionService = new TransactionsService(); const walletService = new WalletService({ logger, diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts index b5c79450..eec59a95 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/assets.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -11,7 +11,7 @@ import type { export class AssetsHandler { async onAssetHistoricalPrice( - params: OnAssetHistoricalPriceArguments, + _params: OnAssetHistoricalPriceArguments, ): Promise { return { historicalPrice: { @@ -22,19 +22,19 @@ export class AssetsHandler { } async onAssetsConversion( - conversions: OnAssetsConversionArguments, + _conversions: OnAssetsConversionArguments, ): Promise { return { conversionRates: {} }; } async onAssetsLookup( - params: OnAssetsLookupArguments, + _params: OnAssetsLookupArguments, ): Promise { return { assets: {} }; } async onAssetsMarketData( - assets: OnAssetsMarketDataArguments, + _assets: OnAssetsMarketDataArguments, ): Promise { return { marketData: {} }; } diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts index 5df88a5b..e73ae2cd 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts @@ -1,7 +1,11 @@ import type { JsonRpcRequest } from '@metamask/utils'; export class CronHandler { - async handle({ request }: { request: JsonRpcRequest }): Promise { + async handle({ + request: _request, + }: { + request: JsonRpcRequest; + }): Promise { /** * Map cronjob to the appropriate handler */ diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index d73c5293..9fda9db4 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -117,7 +117,7 @@ export class KeyringHandler implements Keyring { } #getDefaultDerivationPath(index: number): `m/${string}` { - return `m/44'/195'/${index}'/0/0`; + return `m/44'/195'/0'/0/${index}`; } #getIndexFromDerivationPath(derivationPath: `m/${string}`): number { diff --git a/merged-packages/tron-wallet-snap/src/handlers/userInput.ts b/merged-packages/tron-wallet-snap/src/handlers/userInput.ts index ba1a07ce..991da43d 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/userInput.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/userInput.ts @@ -2,9 +2,9 @@ import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; export class UserInputHandler { async handle({ - id, - event, - context, + id: _id, + event: _event, + context: _context, }: { id: string; event: UserInputEvent; diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 8e3dc31b..6b5f1e39 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -24,12 +24,10 @@ export class AssetsService { readonly #configProvider: ConfigProvider; - readonly #state: State; - constructor({ logger, configProvider, - state, + state: _state, }: { logger: ILogger; configProvider: ConfigProvider; @@ -37,22 +35,22 @@ export class AssetsService { }) { this.#logger = logger; this.#configProvider = configProvider; - this.#state = state; + // TODO: Use state when implementing asset storage } async #listAddressNativeAssets( - address: string, + _address: string, ): Promise { return []; // TODO: Implement me } async #listAddressTokenAssets( - address: string, + _address: string, ): Promise { return []; // TODO: Implement me } - async #listAddressNftAssets(address: string): Promise { + async #listAddressNftAssets(_address: string): Promise { return []; // TODO: Implement me } @@ -102,7 +100,11 @@ export class AssetsService { }; } - #splitAssetsByType(assetTypes: CaipAssetType[]) { + #splitAssetsByType(assetTypes: CaipAssetType[]): { + nativeAssetTypes: NativeCaipAssetType[]; + tokenAssetTypes: TokenCaipAssetType[]; + nftAssetTypes: NftCaipAssetType[]; + } { const nativeAssetTypes = assetTypes.filter((assetType) => assetType.endsWith('slip44:195'), ) as NativeCaipAssetType[]; @@ -146,13 +148,13 @@ export class AssetsService { } async getTokensMetadata( - assetTypes: TokenCaipAssetType[], + _assetTypes: TokenCaipAssetType[], ): Promise> { return {}; // TODO: Implement me } async getNftsMetadata( - assetTypes: NftCaipAssetType[], + _assetTypes: NftCaipAssetType[], ): Promise> { return {}; // TODO: Implement me } diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index 5f650850..6be002fb 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -108,7 +108,7 @@ export class ConfigProvider { this.#config = this.#buildConfig(environment); } - #parseEnvironment() { + #parseEnvironment(): Env { const rawEnvironment = { ENVIRONMENT: process.env.ENVIRONMENT, // RPC diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.test.ts b/merged-packages/tron-wallet-snap/src/services/state/State.test.ts index f68e6acf..213931ea 100644 --- a/merged-packages/tron-wallet-snap/src/services/state/State.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/state/State.test.ts @@ -1,6 +1,6 @@ /* eslint-disable jest/prefer-strict-equal */ /* eslint-disable @typescript-eslint/naming-convention */ -import BigNumber from 'bignumber.js'; +import { BigNumber } from 'bignumber.js'; import { State } from './State'; diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts index 3a71bf90..6c230323 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts @@ -1,28 +1,9 @@ import type { Transaction } from '@metamask/keyring-api'; import type { TronKeyringAccount } from '../../entities'; -import type { ILogger } from '../../utils/logger'; -import type { State, UnencryptedStateValue } from '../state/State'; export class TransactionsService { - readonly #logger: ILogger; - - readonly #loggerPrefix = '[💸 TransactionsService]'; - - readonly #state: State; - - constructor({ - logger, - state, - }: { - logger: ILogger; - state: State; - }) { - this.#logger = logger; - this.#state = state; - } - - async listTransactions(account: TronKeyringAccount): Promise { + async listTransactions(_account: TronKeyringAccount): Promise { return []; // TODO: Implement me } } diff --git a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts index ae52717c..96af09c8 100644 --- a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts +++ b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts @@ -6,17 +6,15 @@ export class WalletService { readonly #loggerPrefix = '[👛 WalletService]'; - readonly #state: State; - constructor({ logger, - state, + state: _state, }: { logger: ILogger; state: State; }) { this.#logger = logger; - this.#state = state; + // TODO: Use state when implementing wallet functionality } async signMessage(message: string): Promise { diff --git a/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts b/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts index ce730a31..5f5f86ea 100644 --- a/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts +++ b/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts @@ -24,7 +24,7 @@ export function buildUrl(params: BuildUrlParams): string { assert(baseUrl, UrlStruct); - const pathWithParams = path.replace(/\{(\w+)\}/gu, (_, key: string) => { + const pathWithParams = path.replace(/\{(\w+)\}/gu, (_match, key: string) => { const value = pathParams?.[key]; if (value === undefined) { throw new Error(`Path parameter ${key} is undefined`); @@ -39,8 +39,8 @@ export function buildUrl(params: BuildUrlParams): string { const url = new URL(cleanPath, baseUrl); Object.entries(queryParams ?? {}) - .filter(([_, value]) => value !== undefined) - .filter(([_, value]) => value !== null) + .filter(([_key, value]) => value !== undefined) + .filter(([_key, value]) => value !== null) .forEach(([key, value]) => { if (value) { url.searchParams.append(key, value); diff --git a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts index 196d06af..633a4a33 100644 --- a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts +++ b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts @@ -7,9 +7,9 @@ describe('getExplorerUrl', () => { // eslint-disable-next-line no-restricted-globals process.env.EXPLORER_MAINNET_BASE_URL = 'https://tronscan.org'; // eslint-disable-next-line no-restricted-globals - process.env.EXPLORER_NILE_BASE_URL = 'https://tronscan.org'; + process.env.EXPLORER_NILE_BASE_URL = 'https://nile.tronscan.org'; // eslint-disable-next-line no-restricted-globals - process.env.EXPLORER_SHASTA_BASE_URL = 'https://tronscan.org'; + process.env.EXPLORER_SHASTA_BASE_URL = 'https://shasta.tronscan.org'; }); const mockAddress = 'TJRabPrwbZy45savqYt9XgVjvjQvQnQqQq'; @@ -23,12 +23,12 @@ describe('getExplorerUrl', () => { it('should generate nile URL for address with cluster param', () => { const url = getExplorerUrl(Network.Nile, 'address', mockAddress); - expect(url).toBe(`https://tronscan.org/#/address/${mockAddress}`); + expect(url).toBe(`https://nile.tronscan.org/#/address/${mockAddress}`); }); it('should generate shasta URL for address with cluster param', () => { const url = getExplorerUrl(Network.Shasta, 'address', mockAddress); - expect(url).toBe(`https://tronscan.org/#/address/${mockAddress}`); + expect(url).toBe(`https://shasta.tronscan.org/#/address/${mockAddress}`); }); it('should generate mainnet URL for transaction without cluster param', () => { @@ -38,11 +38,11 @@ describe('getExplorerUrl', () => { it('should generate nile URL for transaction with cluster param', () => { const url = getExplorerUrl(Network.Nile, 'transaction', mockTx); - expect(url).toBe(`https://tronscan.org/#/transaction/${mockTx}`); + expect(url).toBe(`https://nile.tronscan.org/#/transaction/${mockTx}`); }); it('should generate shasta URL for transaction with cluster param', () => { const url = getExplorerUrl(Network.Shasta, 'transaction', mockTx); - expect(url).toBe(`https://tronscan.org/#/transaction/${mockTx}`); + expect(url).toBe(`https://shasta.tronscan.org/#/transaction/${mockTx}`); }); }); diff --git a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts index 417c2f76..3c88fcc5 100644 --- a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts +++ b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts @@ -13,25 +13,21 @@ export function getExplorerUrl( scope: Network, type: 'address' | 'transaction', value: string, -) { - console.log( - 'process.env.EXPLORER_MAINNET_BASE_URL', - process.env.EXPLORER_MAINNET_BASE_URL, - ); - +): string { + // TODO: Get these URLs from configuration instead of environment variables const NETWORK_TO_EXPLORER_PATH = { + /* eslint-disable-next-line no-restricted-globals */ [Network.Mainnet]: process.env.EXPLORER_MAINNET_BASE_URL as string, + /* eslint-disable-next-line no-restricted-globals */ [Network.Nile]: process.env.EXPLORER_NILE_BASE_URL as string, + /* eslint-disable-next-line no-restricted-globals */ [Network.Shasta]: process.env.EXPLORER_SHASTA_BASE_URL as string, - [Network.Localnet]: process.env.EXPLORER_MAINNET_BASE_URL as string, + /* eslint-disable-next-line no-restricted-globals */ + [Network.Localnet]: process.env.EXPLORER_LOCALNET_BASE_URL as string, }; - console.log('NETWORK_TO_EXPLORER_PATH', NETWORK_TO_EXPLORER_PATH); - const baseUrl = NETWORK_TO_EXPLORER_PATH[scope]; - console.log('baseUrl', baseUrl); - const url = buildUrl({ baseUrl, path: `/#/${type}/${value}`, diff --git a/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts index bcf5126e..fc15d695 100644 --- a/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts +++ b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts @@ -23,7 +23,9 @@ export function getLowestUnusedIndex(items: WithIndex[]): number { return 0; } - const usedIndices = items.map((item) => item.index).sort((a, b) => a - b); + const usedIndices = items + .map((item) => item.index) + .sort((first, second) => first - second); let lowestUnusedIndex = 0; diff --git a/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts b/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts index 69a12472..b860867f 100644 --- a/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts +++ b/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts @@ -19,7 +19,7 @@ export const safeMerge = ( ...overridee, ...(Object.fromEntries( Object.entries(overrider).filter( - ([_, value]) => + ([_key, value]) => value !== undefined && value !== null && (!value || typeof value !== 'object' || Object.keys(value).length > 0), diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts index 4d8843d5..578d822c 100644 --- a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts @@ -1,6 +1,6 @@ /* eslint-disable jest/prefer-strict-equal */ /* eslint-disable @typescript-eslint/naming-convention */ -import BigNumber from 'bignumber.js'; +import { BigNumber } from 'bignumber.js'; import { deserialize } from './deserialize'; diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts index 98112a44..3fc351f8 100644 --- a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts @@ -1,5 +1,5 @@ import type { Json } from '@metamask/snaps-sdk'; -import BigNumber from 'bignumber.js'; +import { BigNumber } from 'bignumber.js'; import type { Serializable } from './types'; @@ -29,7 +29,13 @@ export const deserialize = (serializedValue: Json): Serializable => } if (value.__type === 'Uint8Array') { - return new Uint8Array(Buffer.from(value.value, 'base64')); + // Use TextEncoder to decode base64 string to Uint8Array + const binaryString = atob(value.value); + const bytes = new Uint8Array(binaryString.length); + for (let index = 0; index < binaryString.length; index++) { + bytes[index] = binaryString.charCodeAt(index); + } + return bytes; } return value; diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts index 8d30ec8d..ab07039a 100644 --- a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import BigNumber from 'bignumber.js'; +import { BigNumber } from 'bignumber.js'; import { serialize } from './serialize'; diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts index cd03a46b..c205ebee 100644 --- a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { Json } from '@metamask/snaps-sdk'; -import BigNumber from 'bignumber.js'; +import { BigNumber } from 'bignumber.js'; import { cloneDeepWith } from 'lodash'; import type { Serializable } from './types'; @@ -36,9 +36,14 @@ export const serialize = (value: Serializable): Record => } if (val instanceof Uint8Array) { + // Convert Uint8Array to base64 string without using Buffer + let binaryString = ''; + for (const byte of val) { + binaryString += String.fromCharCode(byte); + } return { __type: 'Uint8Array', - value: Buffer.from(val).toString('base64'), + value: btoa(binaryString), }; } diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts index ad86620e..cb8c3fd7 100644 --- a/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts @@ -1,5 +1,4 @@ import type { Json } from '@metamask/snaps-sdk'; -import type BigNumber from 'bignumber.js'; /** * A primitive value that can be serialized to JSON using the `serialize` function. diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.ts b/merged-packages/tron-wallet-snap/src/validation/structs.ts index c245908e..b224b758 100644 --- a/merged-packages/tron-wallet-snap/src/validation/structs.ts +++ b/merged-packages/tron-wallet-snap/src/validation/structs.ts @@ -58,7 +58,7 @@ export const UrlStruct = refine(string(), 'safe-url', (value) => { // Protocol check const supportedProtocols = ['http:', 'https:', 'wss:']; if (!supportedProtocols.includes(url.protocol)) { - return `URL must use one of the following protocols: ${supportedProtocols}`; + return `URL must use one of the following protocols: ${supportedProtocols.join(', ')}`; } // Validate URL format @@ -133,8 +133,8 @@ export const UrlStruct = refine(string(), 'safe-url', (value) => { /[?&](?:url|redirect|next|return_to|return_url|goto|destination|continue|redirect_uri)=%(?:[^&]*\/\/|https?:)/iu, // URL-encoded open redirect parameters ]; - for (const patt of dangerousPatterns) { - if (patt.test(decodedValue)) { + for (const dangerousPattern of dangerousPatterns) { + if (dangerousPattern.test(decodedValue)) { return 'URL contains potentially malicious patterns'; } } @@ -145,7 +145,7 @@ export const UrlStruct = refine(string(), 'safe-url', (value) => { } return true; - } catch (error) { + } catch { return 'Invalid URL format'; } }); diff --git a/merged-packages/tron-wallet-snap/src/validation/validators.ts b/merged-packages/tron-wallet-snap/src/validation/validators.ts index 3893d8d5..20bca180 100644 --- a/merged-packages/tron-wallet-snap/src/validation/validators.ts +++ b/merged-packages/tron-wallet-snap/src/validation/validators.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-throw-literal */ +/* eslint-disable @typescript-eslint/only-throw-error */ import { InvalidParamsError, SnapError, @@ -32,8 +32,8 @@ export function validateRequest>( ): asserts requestParams is Infer { try { assert(requestParams, struct); - } catch (error: any) { - throw new InvalidParamsError(error.message) as unknown as Error; + } catch (validationError: any) { + throw new InvalidParamsError(validationError.message); } } @@ -51,7 +51,7 @@ export function validateResponse>( ): asserts response is Infer { try { assert(response, struct); - } catch (error) { - throw new SnapError('Invalid Response') as unknown as Error; + } catch { + throw new SnapError('Invalid Response'); } } From a07a08cb980558dc4e26e8ffdc0bd8e59a03b1f3 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 24 Jul 2025 16:07:19 +0100 Subject: [PATCH 006/238] feat: implement account derivation --- merged-packages/tron-wallet-snap/package.json | 1 + .../tron-wallet-snap/snap.manifest.json | 2 +- .../tron-wallet-snap/src/handlers/keyring.ts | 35 +++++++++++--- .../src/services/accounts/AccountsService.ts | 36 ++++++++++++++ .../src/utils/deriveTronKeypair.ts | 47 ++++++++++++------- 5 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 35c9ded1..38f80714 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -52,6 +52,7 @@ "jest-transform-stub": "2.0.0", "lodash": "^4.17.21", "prettier": "^3.5.3", + "tronweb": "6.0.3", "ts-jest": "^29.4.0" }, "publishConfig": { diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index b2efeaca..ff1004e7 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "DB/nbQ3RZwbABP/PC9ZJL3naC7ztEQRk/K7JEB68riM=", + "shasum": "lBUGiWs2C8+CauV7oS0BxjD2rLP0+td/31de/+pjbDc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index 9fda9db4..ff258d87 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -43,7 +43,8 @@ import { withCatchAndThrowSnapError } from '../utils/errors'; import { getLowestUnusedIndex } from '../utils/getLowestUnusedIndex'; import { listEntropySources } from '../utils/interface'; import type { ILogger } from '../utils/logger'; -import { validateOrigin } from '../validation/validators'; +import { DeleteAccountStruct } from '../validation/structs'; +import { validateOrigin, validateRequest } from '../validation/validators'; export class KeyringHandler implements Keyring { readonly #logger: ILogger; @@ -105,6 +106,16 @@ export class KeyringHandler implements Keyring { } } + async #getAccountOrThrow(accountId: string): Promise { + const account = await this.getAccount(accountId); + + if (!account) { + throw new Error(`Account "${accountId}" not found`); + } + + return account; + } + #getLowestUnusedKeyringAccountIndex( accounts: TronKeyringAccount[], entropySource: EntropySourceId, @@ -195,14 +206,11 @@ export class KeyringHandler implements Keyring { return asStrictKeyringAccount(sameAccount); } - const { publicKeyBytes } = await deriveTronKeypair({ + const { address: accountAddress } = await deriveTronKeypair({ entropySource, derivationPath, }); - const accountAddress = 'TT2T17KZhoDu47i2E4FWxfG79zdkEWkU9N'; // TODO: Replace this mock - - // Filter out our special properties from options const { importedAccount, accountNameSuggestion, @@ -313,8 +321,21 @@ export class KeyringHandler implements Keyring { throw new Error('Method not implemented.'); } - async deleteAccount(id: string): Promise { - throw new Error('Method not implemented.'); + async deleteAccount(accountId: string): Promise { + try { + validateRequest({ accountId }, DeleteAccountStruct); + + const account = await this.#getAccountOrThrow(accountId); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountDeleted, { + id: accountId, + }); + + await this.#deleteAccountFromState(accountId); + } catch (error: any) { + this.#logger.error({ error }, 'Error deleting account'); + throw error; + } } exportAccount?(id: string): Promise { diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts new file mode 100644 index 00000000..45e2f7af --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -0,0 +1,36 @@ +import type { TronKeyringAccount } from '../../entities'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +export class AccountsService { + readonly #state: IStateManager; + + constructor(state: IStateManager) { + this.#state = state; + } + + /** + * Returns all accounts from the state. + * + * @returns All accounts from the state. + */ + async getAll(): Promise { + const accounts = + await this.#state.getKey( + 'keyringAccounts', + ); + + return Object.values(accounts ?? {}); + } + + async findById(id: string): Promise { + const accounts = await this.getAll(); + return accounts.find((account) => account.id === id) ?? null; + } + + async findByAddress(address: string): Promise { + const accounts = await this.getAll(); + + return accounts.find((account) => account.address === address) ?? null; + } +} diff --git a/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts b/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts index ebd6421e..6ab93605 100644 --- a/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts +++ b/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts @@ -1,6 +1,7 @@ import type { EntropySourceId } from '@metamask/keyring-api'; import { assert, pattern, string } from '@metamask/superstruct'; import { hexToBytes } from '@metamask/utils'; +import { TronWeb } from 'tronweb'; import { getBip32Entropy } from './getBip32Entropy'; import logger from './logger'; @@ -12,28 +13,26 @@ const DERIVATION_PATH_REGEX = /^m\/44'\/195'/u; export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); /** - * Elliptic curve - * - * See: https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ed25519/ + * Elliptic curve for TRON (same as Ethereum) */ const CURVE = 'secp256k1' as const; /** - * Derives a Tron private and public key from a given index using BIP44 derivation path. - * The derivation path follows Phantom wallet's standard: m/44'/501'/index'/0'. + * Derives a TRON private and public key from a given derivation path using BIP44. + * The derivation path follows the format: m/44'/195'/account'/change/index + * where 195 is TRON's coin type. * - * @param params - The parameters for the Tron key derivation. + * @param params - The parameters for the TRON key derivation. * @param params.entropySource - The entropy source to use for key derivation. * @param params.derivationPath - The derivation path to use for key derivation. - * @returns A Promise that resolves to a Uint8Array of the private key. + * @returns A Promise that resolves to the private key, public key, and address. * @throws {Error} If unable to derive private key or if derivation fails. * @example * ```typescript - * const { privateKeyBytes, publicKeyBytes } = await deriveSolanaPrivateKey(0); + * const { privateKeyBytes, publicKeyBytes, address } = await deriveTronKeypair({ + * derivationPath: "m/44'/195'/0'/0/0" + * }); * ``` - * @see {@link https://help.phantom.app/hc/en-us/articles/12988493966227-What-derivation-paths-does-Phantom-wallet-support} Phantom wallet derivation paths - * @see {@link https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki} BIP44 specification - * @see {@link https://github.com/satoshilabs/slips/blob/master/slip-0044.md} SLIP-0044 for coin types. */ export async function deriveTronKeypair({ entropySource, @@ -41,8 +40,12 @@ export async function deriveTronKeypair({ }: { entropySource?: EntropySourceId | undefined; derivationPath: string; -}): Promise<{ privateKeyBytes: Uint8Array; publicKeyBytes: Uint8Array }> { - logger.log({ derivationPath }, 'Generating solana wallet'); +}): Promise<{ + privateKeyBytes: Uint8Array; + publicKeyBytes: Uint8Array; + address: string; +}> { + logger.log({ derivationPath }, 'Generating TRON wallet'); assert(derivationPath, DerivationPathStruct); @@ -59,12 +62,22 @@ export async function deriveTronKeypair({ throw new Error('Unable to derive private key'); } + const privateKeyBytes = hexToBytes(node.privateKey); + const publicKeyBytes = hexToBytes(node.publicKey); + + const address = TronWeb.address.fromPrivateKey(node.privateKey.slice(2)); + + if (!address) { + throw new Error('Unable to derive address'); + } + return { - privateKeyBytes: hexToBytes(node.privateKey), - publicKeyBytes: hexToBytes(node.publicKey), + privateKeyBytes, + publicKeyBytes, + address, }; } catch (error: any) { - logger.error({ error }, 'Error deriving keypair'); - throw new Error(error); + logger.error({ error: error.message }, 'Error deriving TRON keypair'); + throw new Error(`Failed to derive TRON keypair: ${error.message}`); } } From 489912c32600681c90c8491610386730784c1629 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Sat, 26 Jul 2025 09:38:11 +0200 Subject: [PATCH 007/238] feat: refactor accounts service --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../tron-wallet-snap/src/context.ts | 13 +- .../tron-wallet-snap/src/handlers/keyring.ts | 201 ++---------------- .../services/accounts/AccountsRepository.ts | 50 +++++ .../src/services/accounts/AccountsService.ts | 186 ++++++++++++++-- .../src/services/accounts/types.ts | 9 + 6 files changed, 251 insertions(+), 210 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/accounts/types.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index ff1004e7..72f4fc45 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "lBUGiWs2C8+CauV7oS0BxjD2rLP0+td/31de/+pjbDc=", + "shasum": "Q1UTXME5LTlAfUdq0ixv8qIZZ9It8+MGDEXPnorwYMk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 4f2dc3a6..5fa8c1ca 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -3,6 +3,8 @@ import { CronHandler } from './handlers/cronjob'; import { KeyringHandler } from './handlers/keyring'; import { RpcHandler } from './handlers/rpc'; import { UserInputHandler } from './handlers/userInput'; +import { AccountsRepository } from './services/accounts/AccountsRepository'; +import { AccountsService } from './services/accounts/AccountsService'; import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; import type { UnencryptedStateValue } from './services/state/State'; @@ -39,6 +41,10 @@ const walletService = new WalletService({ state, }); +const accountsRepository = new AccountsRepository(state); + +const accountsService = new AccountsService(accountsRepository, logger); + /** * Handlers */ @@ -46,10 +52,7 @@ const assetsHandler = new AssetsHandler(); const cronHandler = new CronHandler(); const keyringHandler = new KeyringHandler({ logger, - state, - assetsService, - transactionService, - walletService, + accountsService, }); const rpcHandler = new RpcHandler(); const userInputHandler = new UserInputHandler(); @@ -62,6 +65,7 @@ export type SnapExecutionContext = { assetsService: AssetsService; transactionService: TransactionsService; walletService: WalletService; + accountsService: AccountsService; /** * Handlers */ @@ -80,6 +84,7 @@ const snapContext: SnapExecutionContext = { assetsService, transactionService, walletService, + accountsService, /** * Handlers */ diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index ff258d87..f010e3a5 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -1,8 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { KeyringEvent, - TrxAccountType, - TrxScope, type Balance, type DiscoveredAccount, type EntropySourceId, @@ -11,7 +9,6 @@ import { type KeyringAccountData, type KeyringRequest, type KeyringResponse, - type MetaMaskOptions, type Paginated, type Pagination, type ResolvedAccountAddress, @@ -22,7 +19,6 @@ import { handleKeyringRequest, } from '@metamask/keyring-snap-sdk'; import { SnapError } from '@metamask/snaps-sdk'; -import { assert, integer } from '@metamask/superstruct'; import type { CaipAssetType, CaipAssetTypeOrId, @@ -33,15 +29,9 @@ import type { import { sortBy } from 'lodash'; import type { TronKeyringAccount } from '../entities'; -import { asStrictKeyringAccount } from '../entities/keyring-account'; -import type { AssetsService } from '../services/assets/AssetsService'; -import type { State, UnencryptedStateValue } from '../services/state/State'; -import type { TransactionsService } from '../services/transactions/TransactionsService'; -import type { WalletService } from '../services/wallet/WalletService'; -import { deriveTronKeypair } from '../utils/deriveTronKeypair'; +import type { AccountsService } from '../services/accounts/AccountsService'; +import type { CreateAccountOptions } from '../services/accounts/types'; import { withCatchAndThrowSnapError } from '../utils/errors'; -import { getLowestUnusedIndex } from '../utils/getLowestUnusedIndex'; -import { listEntropySources } from '../utils/interface'; import type { ILogger } from '../utils/logger'; import { DeleteAccountStruct } from '../validation/structs'; import { validateOrigin, validateRequest } from '../validation/validators'; @@ -49,23 +39,17 @@ import { validateOrigin, validateRequest } from '../validation/validators'; export class KeyringHandler implements Keyring { readonly #logger: ILogger; - readonly #state: State; + readonly #accountsService: AccountsService; constructor({ logger, - state, - assetsService, - transactionService, - walletService, + accountsService, }: { logger: ILogger; - state: State; - assetsService: AssetsService; - transactionService: TransactionsService; - walletService: WalletService; + accountsService: AccountsService; }) { this.#logger = logger; - this.#state = state; + this.#accountsService = accountsService; } async handle(origin: string, request: JsonRpcRequest): Promise { @@ -81,12 +65,9 @@ export class KeyringHandler implements Keyring { async listAccounts(): Promise { try { - const keyringAccounts = - (await this.#state.getKey( - 'keyringAccounts', - )) ?? {}; + const keyringAccounts = await this.#accountsService.getAll(); - return sortBy(Object.values(keyringAccounts), ['entropySource', 'index']); + return sortBy(keyringAccounts, ['entropySource', 'index']); } catch (error: any) { this.#logger.error({ error }, 'Error listing accounts'); throw new Error('Error listing accounts'); @@ -95,9 +76,8 @@ export class KeyringHandler implements Keyring { async getAccount(accountId: string): Promise { try { - const account = await this.#state.getKey( - `keyringAccounts.${accountId}`, - ); + const account = + (await this.#accountsService.findById(accountId)) ?? undefined; return account; } catch (error: any) { @@ -116,165 +96,16 @@ export class KeyringHandler implements Keyring { return account; } - #getLowestUnusedKeyringAccountIndex( - accounts: TronKeyringAccount[], - entropySource: EntropySourceId, - ): number { - const accountsFilteredByEntropySourceId = accounts.filter( - (account) => account.entropySource === entropySource, - ); - - return getLowestUnusedIndex(accountsFilteredByEntropySourceId); - } - - #getDefaultDerivationPath(index: number): `m/${string}` { - return `m/44'/195'/0'/0/${index}`; - } - - #getIndexFromDerivationPath(derivationPath: `m/${string}`): number { - const levels = derivationPath.split('/'); - const indexLevel = levels[3]; - - if (!indexLevel) { - throw new Error('Invalid derivation path'); - } - - const index = parseInt(indexLevel.replace("'", ''), 10); - assert(index, integer()); - - return index; - } - - async #getDefaultEntropySource(): Promise { - const entropySources = await listEntropySources(); - const defaultEntropySource = entropySources.find(({ primary }) => primary); - - if (!defaultEntropySource) { - throw new Error( - 'No default entropy source found - this can never happen', - ); - } - - return defaultEntropySource.id; - } - - async #deleteAccountFromState(accountId: string): Promise { - await Promise.all([ - this.#state.deleteKey(`keyringAccounts.${accountId}`), - this.#state.deleteKey(`transactions.${accountId}`), - this.#state.deleteKey(`assets.${accountId}`), - ]); - } - - async createAccount( - options?: { - entropySource?: EntropySourceId; - derivationPath?: `m/${string}`; - accountNameSuggestion?: string; - [key: string]: Json | undefined; - } & MetaMaskOptions, - ): Promise { + async createAccount(options?: CreateAccountOptions): Promise { const id = globalThis.crypto.randomUUID(); try { - const accounts = await this.listAccounts(); - - const entropySource = - options?.entropySource ?? (await this.#getDefaultEntropySource()); - - const index = options?.derivationPath - ? this.#getIndexFromDerivationPath(options.derivationPath) - : this.#getLowestUnusedKeyringAccountIndex(accounts, entropySource); - - const derivationPath = - options?.derivationPath ?? this.#getDefaultDerivationPath(index); - - /** - * Now that we have the `entropySource` and `derivationPath` ready, - * we need to make sure that they do not correspond to an existing account already. - */ - const sameAccount = accounts.find( - (account) => - account.derivationPath === derivationPath && - account.entropySource === entropySource, - ); - - if (sameAccount) { - this.#logger.warn( - '[🔑 Keyring] An account already exists with the same derivation path and entropy source. Skipping account creation.', - ); - return asStrictKeyringAccount(sameAccount); - } - - const { address: accountAddress } = await deriveTronKeypair({ - entropySource, - derivationPath, - }); + const account = await this.#accountsService.create(id, options); - const { - importedAccount, - accountNameSuggestion, - metamask: metamaskOptions, - ...remainingOptions - } = options ?? {}; - - const solanaKeyringAccount: TronKeyringAccount = { - id, - entropySource, - derivationPath, - index, - type: TrxAccountType.Eoa, - address: accountAddress, - scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], - options: { - ...remainingOptions, - /** - * Make sure to save the `entropySource`, `derivationPath` and `index` - * in the keyring account options.. - */ - entropySource, - derivationPath, - index, - }, - methods: ['signMessageV2', 'verifyMessageV2'], - }; - - await this.#state.setKey( - `keyringAccounts.${solanaKeyringAccount.id}`, - solanaKeyringAccount, - ); - - const keyringAccount = asStrictKeyringAccount(solanaKeyringAccount); - - await emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { - /** - * We can't pass the `keyringAccount` object because it contains the index - * and the snaps sdk does not allow extra properties. - */ - account: keyringAccount, - accountNameSuggestion: - accountNameSuggestion ?? `Tron Account ${index + 1}`, - displayAccountNameSuggestion: !accountNameSuggestion, - /** - * Skip account creation confirmation dialogs to make it look like a native - * account creation flow. - */ - displayConfirmation: false, - /** - * Internal options to MetaMask that includes a correlation ID. We need - * to also emit this ID to the Snap keyring. - */ - ...(metamaskOptions - ? { - metamask: metamaskOptions, - } - : {}), - }); - - return keyringAccount; + return account; } catch (error: any) { this.#logger.error({ error }, 'Error creating account'); - await this.#deleteAccountFromState(id); + await this.#accountsService.delete(id); throw new Error(`Error creating account: ${error.message}`); } @@ -328,10 +159,10 @@ export class KeyringHandler implements Keyring { const account = await this.#getAccountOrThrow(accountId); await emitSnapKeyringEvent(snap, KeyringEvent.AccountDeleted, { - id: accountId, + id: account.id, }); - await this.#deleteAccountFromState(accountId); + await this.#accountsService.delete(accountId); } catch (error: any) { this.#logger.error({ error }, 'Error deleting account'); throw error; diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts new file mode 100644 index 00000000..30174516 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts @@ -0,0 +1,50 @@ +import type { TronKeyringAccount } from '../../entities'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +export class AccountsRepository { + readonly #state: IStateManager; + + constructor(state: IStateManager) { + this.#state = state; + } + + /** + * Returns all accounts from the state. + * + * @returns All accounts from the state. + */ + async getAll(): Promise { + const accounts = + await this.#state.getKey( + 'keyringAccounts', + ); + + return Object.values(accounts ?? {}); + } + + async findById(id: string): Promise { + const accounts = await this.getAll(); + return accounts.find((account) => account.id === id) ?? null; + } + + async findByAddress(address: string): Promise { + const accounts = await this.getAll(); + + return accounts.find((account) => account.address === address) ?? null; + } + + async create(account: TronKeyringAccount): Promise { + await this.#state.setKey(`keyringAccounts.${account.id}`, account); + + return account; + } + + async delete(id: string): Promise { + await Promise.all([ + this.#state.deleteKey(`keyringAccounts.${id}`), + this.#state.deleteKey(`assets.${id}`), + this.#state.deleteKey(`transactions.${id}`), + ]); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 45e2f7af..7ca422f3 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -1,36 +1,182 @@ -import type { TronKeyringAccount } from '../../entities'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { assert, integer } from '@metamask/superstruct'; + +import type { AccountsRepository } from './AccountsRepository'; +import type { CreateAccountOptions } from './types'; +import { + asStrictKeyringAccount, + type TronKeyringAccount, +} from '../../entities'; +import { deriveTronKeypair } from '../../utils/deriveTronKeypair'; +import { getLowestUnusedIndex } from '../../utils/getLowestUnusedIndex'; +import { listEntropySources } from '../../utils/interface'; +import type { ILogger } from '../../utils/logger'; export class AccountsService { - readonly #state: IStateManager; + readonly #accountsRepository: AccountsRepository; + + readonly #logger: ILogger; - constructor(state: IStateManager) { - this.#state = state; + constructor(accountsRepository: AccountsRepository, logger: ILogger) { + this.#accountsRepository = accountsRepository; + this.#logger = logger; } - /** - * Returns all accounts from the state. - * - * @returns All accounts from the state. - */ - async getAll(): Promise { - const accounts = - await this.#state.getKey( - 'keyringAccounts', + async create( + id: string, + options?: CreateAccountOptions, + ): Promise { + const accounts = await this.#accountsRepository.getAll(); + + const entropySource = + options?.entropySource ?? (await this.#getDefaultEntropySource()); + + const index = options?.derivationPath + ? this.#getIndexFromDerivationPath(options.derivationPath) + : this.#getLowestUnusedKeyringAccountIndex(accounts, entropySource); + + const derivationPath = + options?.derivationPath ?? this.#getDefaultDerivationPath(index); + + /** + * Now that we have the `entropySource` and `derivationPath` ready, + * we need to make sure that they do not correspond to an existing account already. + */ + const sameAccount = accounts.find( + (account) => + account.derivationPath === derivationPath && + account.entropySource === entropySource, + ); + + if (sameAccount) { + this.#logger.warn( + '[🔑 Keyring] An account already exists with the same derivation path and entropy source. Skipping account creation.', ); + return asStrictKeyringAccount(sameAccount); + } + + const { address: accountAddress } = await deriveTronKeypair({ + entropySource, + derivationPath, + }); - return Object.values(accounts ?? {}); + const { + importedAccount, + accountNameSuggestion, + metamask: metamaskOptions, + ...remainingOptions + } = options ?? {}; + + const tronKeyringAccount: TronKeyringAccount = { + id, + entropySource, + derivationPath, + index, + type: TrxAccountType.Eoa, + address: accountAddress, + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + ...remainingOptions, + /** + * Make sure to save the `entropySource`, `derivationPath` and `index` + * in the keyring account options.. + */ + entropySource, + derivationPath, + index, + }, + methods: ['signMessageV2', 'verifyMessageV2'], + }; + + await this.#accountsRepository.create(tronKeyringAccount); + + const keyringAccount = asStrictKeyringAccount(tronKeyringAccount); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { + /** + * We can't pass the `keyringAccount` object because it contains the index + * and the snaps sdk does not allow extra properties. + */ + account: keyringAccount, + accountNameSuggestion: + accountNameSuggestion ?? `Tron Account ${index + 1}`, + displayAccountNameSuggestion: !accountNameSuggestion, + /** + * Skip account creation confirmation dialogs to make it look like a native + * account creation flow. + */ + displayConfirmation: false, + /** + * Internal options to MetaMask that includes a correlation ID. We need + * to also emit this ID to the Snap keyring. + */ + ...(metamaskOptions + ? { + metamask: metamaskOptions, + } + : {}), + }); + + return keyringAccount; + } + + async getAll(): Promise { + return this.#accountsRepository.getAll(); } async findById(id: string): Promise { - const accounts = await this.getAll(); - return accounts.find((account) => account.id === id) ?? null; + return this.#accountsRepository.findById(id); } async findByAddress(address: string): Promise { - const accounts = await this.getAll(); + return this.#accountsRepository.findByAddress(address); + } + + async delete(id: string): Promise { + return this.#accountsRepository.delete(id); + } + + #getLowestUnusedKeyringAccountIndex( + accounts: TronKeyringAccount[], + entropySource: EntropySourceId, + ): number { + const accountsFilteredByEntropySourceId = accounts.filter( + (account) => account.entropySource === entropySource, + ); + + return getLowestUnusedIndex(accountsFilteredByEntropySourceId); + } + + #getDefaultDerivationPath(index: number): `m/${string}` { + return `m/44'/195'/0'/0/${index}`; + } + + #getIndexFromDerivationPath(derivationPath: `m/${string}`): number { + const levels = derivationPath.split('/'); + const indexLevel = levels[3]; + + if (!indexLevel) { + throw new Error('Invalid derivation path'); + } + + const index = parseInt(indexLevel.replace("'", ''), 10); + assert(index, integer()); + + return index; + } + + async #getDefaultEntropySource(): Promise { + const entropySources = await listEntropySources(); + const defaultEntropySource = entropySources.find(({ primary }) => primary); + + if (!defaultEntropySource) { + throw new Error( + 'No default entropy source found - this can never happen', + ); + } - return accounts.find((account) => account.address === address) ?? null; + return defaultEntropySource.id; } } diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/types.ts b/merged-packages/tron-wallet-snap/src/services/accounts/types.ts new file mode 100644 index 00000000..2f30c482 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/accounts/types.ts @@ -0,0 +1,9 @@ +import type { EntropySourceId, MetaMaskOptions } from '@metamask/keyring-api'; +import type { Json } from '@metamask/utils'; + +export type CreateAccountOptions = { + entropySource?: EntropySourceId; + derivationPath?: `m/${string}`; + accountNameSuggestion?: string; + [key: string]: Json | undefined; +} & MetaMaskOptions; From 30a6e914d9a97750d9dda6c7838d9a6d99d0b0d7 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Fri, 25 Jul 2025 14:30:21 +0200 Subject: [PATCH 008/238] chore: mock assets --- merged-packages/tron-wallet-snap/package.json | 3 + .../src/clients/tronweb/TronWeb.ts | 32 ++++++++ .../tron-wallet-snap/src/context.ts | 4 + .../tron-wallet-snap/src/handlers/assets.ts | 20 ++++- .../tron-wallet-snap/src/handlers/keyring.ts | 17 +++- .../src/services/assets/AssetsService.ts | 78 +++++++++++++++---- 6 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/clients/tronweb/TronWeb.ts diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 38f80714..852a837e 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -58,5 +58,8 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "tronweb": "^6.0.3" } } diff --git a/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWeb.ts b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWeb.ts new file mode 100644 index 00000000..10635563 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWeb.ts @@ -0,0 +1,32 @@ +import { TronWeb } from 'tronweb'; +import type { Account } from 'tronweb/lib/esm/types'; + +export class TronWebClient { + #staticInstance: TronWebClient | null = null; + + readonly #tronWeb: TronWeb; + + constructor() { + this.#tronWeb = new TronWeb({ + fullHost: 'https://api.trongrid.io', + headers: { + 'TRON-PRO-API-KEY': '6e4cbe0c-0bfb-4ea0-bdf8-526c0664801a', + }, + }); + } + + public static getInstance(): TronWebClient { + if (!TronWebClient.prototype.#staticInstance) { + TronWebClient.prototype.#staticInstance = new TronWebClient(); + } + return TronWebClient.prototype.#staticInstance; + } + + public async getAccount(address: string): Promise { + return this.#tronWeb.trx.getAccount(address); + } + + public get tronWeb(): TronWeb { + return this.#tronWeb; + } +} diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 5fa8c1ca..d355da70 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -1,3 +1,4 @@ +import { TronWebClient } from './clients/tronweb/TronWeb'; import { AssetsHandler } from './handlers/assets'; import { CronHandler } from './handlers/cronjob'; import { KeyringHandler } from './handlers/keyring'; @@ -28,10 +29,13 @@ const state = new State({ }, }); +const tronWebClient = new TronWebClient(); + const assetsService = new AssetsService({ logger, configProvider, state, + tronWebClient, }); const transactionService = new TransactionsService(); diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts index eec59a95..65df0a50 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/assets.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -9,6 +9,8 @@ import type { OnAssetsMarketDataResponse, } from '@metamask/snaps-sdk'; +import context from '../context'; + export class AssetsHandler { async onAssetHistoricalPrice( _params: OnAssetHistoricalPriceArguments, @@ -24,13 +26,27 @@ export class AssetsHandler { async onAssetsConversion( _conversions: OnAssetsConversionArguments, ): Promise { - return { conversionRates: {} }; + return { + conversionRates: { + 'tron:728126428/slip44:195': { + 'swift:0/iso4217:USD': { + rate: '0.27', + conversionTime: Date.now(), + expirationTime: Date.now() + 1000 * 60 * 60 * 24, + }, + }, + }, + }; } async onAssetsLookup( _params: OnAssetsLookupArguments, ): Promise { - return { assets: {} }; + const assets = await context.assetsService.getAssetsMetadata(params.assets); + + return { + assets, + }; } async onAssetsMarketData( diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index f010e3a5..19f8f794 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -31,6 +31,7 @@ import { sortBy } from 'lodash'; import type { TronKeyringAccount } from '../entities'; import type { AccountsService } from '../services/accounts/AccountsService'; import type { CreateAccountOptions } from '../services/accounts/types'; +import type { AssetsService } from '../services/assets/AssetsService'; import { withCatchAndThrowSnapError } from '../utils/errors'; import type { ILogger } from '../utils/logger'; import { DeleteAccountStruct } from '../validation/structs'; @@ -41,15 +42,20 @@ export class KeyringHandler implements Keyring { readonly #accountsService: AccountsService; + readonly #assetsService: AssetsService; + constructor({ logger, accountsService, + assetsService, }: { logger: ILogger; accountsService: AccountsService; + assetsService: AssetsService; }) { this.#logger = logger; this.#accountsService = accountsService; + this.#assetsService = assetsService; } async handle(origin: string, request: JsonRpcRequest): Promise { @@ -111,8 +117,10 @@ export class KeyringHandler implements Keyring { } } - listAccountAssets?(id: string): Promise { - throw new Error('Method not implemented.'); + async listAccountAssets(id: string): Promise { + const account = await this.#getAccountOrThrow(id); + + return this.#assetsService.listAccountAssets(account); } listAccountTransactions?( @@ -130,11 +138,12 @@ export class KeyringHandler implements Keyring { throw new Error('Method not implemented.'); } - getAccountBalances?( + async getAccountBalances( id: string, assets: CaipAssetType[], ): Promise> { - throw new Error('Method not implemented.'); + const account = await this.#getAccountOrThrow(id); + return this.#assetsService.getAccountBalances(account, assets); } resolveAccountAddress?( diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 6b5f1e39..6f95bd64 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,3 +1,4 @@ +import type { Balance } from '@metamask/keyring-api'; import type { AssetMetadata, FungibleAssetMetadata, @@ -7,6 +8,7 @@ import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; import { uniq } from 'lodash'; +import type { TronWebClient } from '../../clients/tronweb/TronWeb'; import type { TronKeyringAccount } from '../../entities'; import type { ILogger } from '../../utils/logger'; import type { ConfigProvider } from '../config'; @@ -22,26 +24,37 @@ export class AssetsService { readonly #loggerPrefix = '[🪙 AssetsService]'; - readonly #configProvider: ConfigProvider; + // readonly #configProvider: ConfigProvider; + + // readonly #state: State; + + readonly #tronWebClient: TronWebClient; constructor({ logger, - configProvider, - state: _state, + // configProvider, + // state, + tronWebClient, }: { logger: ILogger; configProvider: ConfigProvider; state: State; + tronWebClient: TronWebClient; }) { this.#logger = logger; - this.#configProvider = configProvider; - // TODO: Use state when implementing asset storage + // this.#configProvider = configProvider; + // this.#state = state; + this.#tronWebClient = tronWebClient; } async #listAddressNativeAssets( - _address: string, + address: string, ): Promise { - return []; // TODO: Implement me + const result = await this.#tronWebClient.getAccount(address); + + console.log('result', result.assetV2); + + return ['tron:728126428/slip44:195']; } async #listAddressTokenAssets( @@ -74,6 +87,18 @@ export class AssetsService { return uniq([...nativeAssetsIds, ...tokenAssetsIds, ...nftAssetsIds]); } + async getAccountBalances( + account: TronKeyringAccount, + assetTypes: CaipAssetType[], + ): Promise> { + return { + 'tron:728126428/slip44:195': { + unit: 'TRX', + amount: '10000', + }, + }; + } + async getAssetsMetadata( assetTypes: CaipAssetType[], ): Promise> { @@ -128,16 +153,18 @@ export class AssetsService { > = {}; for (const assetType of assetTypes) { - const { chainId } = parseCaipAssetType(assetType); + // const { chainId } = parseCaipAssetType(assetType); nativeTokensMetadata[assetType] = { name: 'TRON', symbol: 'TRX', - fungible: true, - iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, + fungible: true as const, + // iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, + iconUrl: + 'https://altcoinsbox.com/wp-content/uploads/2023/01/tron-coin-logo-300x300.webp', units: [ { name: 'TRON', - symbol: 'SOL', + symbol: 'TRX', decimals: 9, }, ], @@ -148,9 +175,34 @@ export class AssetsService { } async getTokensMetadata( - _assetTypes: TokenCaipAssetType[], + assetTypes: TokenCaipAssetType[], ): Promise> { - return {}; // TODO: Implement me + const metadata = await Promise.all( + assetTypes.map(async (assetType) => { + const { assetReference } = parseCaipAssetType(assetType); + + const contract = await this.#tronWebClient.tronWeb + .contract() + .at(assetReference); + const name = await contract.name(); + const symbol = await contract.symbol(); + const decimals = await contract.decimals(); + + return { + [assetType]: { + name, + symbol, + fungible: true as const, + iconUrl: '', + units: [{ decimals, symbol }], + }, + }; + }), + ); + + return metadata.reduce((acc, curr) => { + return { ...acc, ...curr }; + }, {}); } async getNftsMetadata( From 3675b67ed36b9a8dab4ab078693e6cd3ded86093 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Wed, 30 Jul 2025 16:37:22 +0200 Subject: [PATCH 009/238] feat: list trx and trc10 assets --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/tronweb/TronWeb.ts | 32 -- .../tron-wallet-snap/src/context.ts | 19 +- .../tron-wallet-snap/src/entities/assets.ts | 42 ++ .../tron-wallet-snap/src/handlers/assets.ts | 2 +- .../tron-wallet-snap/src/handlers/keyring.ts | 86 +++- .../services/accounts/AccountsRepository.ts | 13 +- .../src/services/accounts/AccountsService.ts | 56 ++- .../services/accounts/AccountsSynchronizer.ts | 22 + .../src/services/assets/AssetsRepository.ts | 31 ++ .../src/services/assets/AssetsService.ts | 475 ++++++++++++++---- .../src/services/config/ConfigProvider.ts | 2 +- .../src/services/connection/Connection.ts | 51 ++ .../src/services/state/State.ts | 6 +- .../tron-wallet-snap/src/utils/logger.ts | 27 + 15 files changed, 699 insertions(+), 167 deletions(-) delete mode 100644 merged-packages/tron-wallet-snap/src/clients/tronweb/TronWeb.ts create mode 100644 merged-packages/tron-wallet-snap/src/entities/assets.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/accounts/AccountsSynchronizer.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/connection/Connection.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 72f4fc45..cead738b 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "Q1UTXME5LTlAfUdq0ixv8qIZZ9It8+MGDEXPnorwYMk=", + "shasum": "uPsIeVFJSw2uNClvPEyzhIZo463Y8aSKM/nkzm2sC54=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWeb.ts b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWeb.ts deleted file mode 100644 index 10635563..00000000 --- a/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWeb.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TronWeb } from 'tronweb'; -import type { Account } from 'tronweb/lib/esm/types'; - -export class TronWebClient { - #staticInstance: TronWebClient | null = null; - - readonly #tronWeb: TronWeb; - - constructor() { - this.#tronWeb = new TronWeb({ - fullHost: 'https://api.trongrid.io', - headers: { - 'TRON-PRO-API-KEY': '6e4cbe0c-0bfb-4ea0-bdf8-526c0664801a', - }, - }); - } - - public static getInstance(): TronWebClient { - if (!TronWebClient.prototype.#staticInstance) { - TronWebClient.prototype.#staticInstance = new TronWebClient(); - } - return TronWebClient.prototype.#staticInstance; - } - - public async getAccount(address: string): Promise { - return this.#tronWeb.trx.getAccount(address); - } - - public get tronWeb(): TronWeb { - return this.#tronWeb; - } -} diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index d355da70..17cbba04 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -1,4 +1,3 @@ -import { TronWebClient } from './clients/tronweb/TronWeb'; import { AssetsHandler } from './handlers/assets'; import { CronHandler } from './handlers/cronjob'; import { KeyringHandler } from './handlers/keyring'; @@ -6,8 +5,10 @@ import { RpcHandler } from './handlers/rpc'; import { UserInputHandler } from './handlers/userInput'; import { AccountsRepository } from './services/accounts/AccountsRepository'; import { AccountsService } from './services/accounts/AccountsService'; +import { AssetsRepository } from './services/assets/AssetsRepository'; import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; +import { Connection } from './services/connection/Connection'; import type { UnencryptedStateValue } from './services/state/State'; import { State } from './services/state/State'; import { TransactionsService } from './services/transactions/TransactionsService'; @@ -29,13 +30,14 @@ const state = new State({ }, }); -const tronWebClient = new TronWebClient(); +const assetsRepository = new AssetsRepository(state); +const connection = new Connection(configProvider); const assetsService = new AssetsService({ logger, - configProvider, state, - tronWebClient, + assetsRepository, + connection, }); const transactionService = new TransactionsService(); @@ -47,7 +49,13 @@ const walletService = new WalletService({ const accountsRepository = new AccountsRepository(state); -const accountsService = new AccountsService(accountsRepository, logger); +const accountsService = new AccountsService( + accountsRepository, + configProvider, + logger, + connection, + assetsService, +); /** * Handlers @@ -57,6 +65,7 @@ const cronHandler = new CronHandler(); const keyringHandler = new KeyringHandler({ logger, accountsService, + assetsService, }); const rpcHandler = new RpcHandler(); const userInputHandler = new UserInputHandler(); diff --git a/merged-packages/tron-wallet-snap/src/entities/assets.ts b/merged-packages/tron-wallet-snap/src/entities/assets.ts new file mode 100644 index 00000000..ae5357cf --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/entities/assets.ts @@ -0,0 +1,42 @@ +import type { Network } from '../constants'; +import type { + NativeCaipAssetType, + TokenCaipAssetType, + NftCaipAssetType, +} from '../services/assets/types'; + +export type NativeAsset = { + assetType: NativeCaipAssetType; + keyringAccountId: string; + network: Network; + address: string; + symbol: string; + decimals: number; + rawAmount: string; // Without decimals + uiAmount: string; // With decimals +}; + +export type TokenAsset = { + assetType: TokenCaipAssetType; // Using the mint + keyringAccountId: string; + network: Network; + mint: string; + pubkey: string; + symbol: string; + decimals: number; + rawAmount: string; // Without decimals + uiAmount: string; // With decimals +}; + +export type NftAsset = { + assetType: NftCaipAssetType; + keyringAccountId: string; + network: Network; + mint: string; + pubkey: string; + symbol: string; + rawAmount: string; // Without decimals + uiAmount: string; // With decimals +}; + +export type AssetEntity = NativeAsset | TokenAsset | NftAsset; diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts index 65df0a50..aa35e1a8 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/assets.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -40,7 +40,7 @@ export class AssetsHandler { } async onAssetsLookup( - _params: OnAssetsLookupArguments, + params: OnAssetsLookupArguments, ): Promise { const assets = await context.assetsService.getAssetsMetadata(params.assets); diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index 19f8f794..d2de1349 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { KeyringEvent, + ListAccountAssetsResponseStruct, type Balance, type DiscoveredAccount, type EntropySourceId, @@ -33,9 +34,18 @@ import type { AccountsService } from '../services/accounts/AccountsService'; import type { CreateAccountOptions } from '../services/accounts/types'; import type { AssetsService } from '../services/assets/AssetsService'; import { withCatchAndThrowSnapError } from '../utils/errors'; -import type { ILogger } from '../utils/logger'; -import { DeleteAccountStruct } from '../validation/structs'; -import { validateOrigin, validateRequest } from '../validation/validators'; +import { createPrefixedLogger, type ILogger } from '../utils/logger'; +import { + DeleteAccountStruct, + GetAccounBalancesResponseStruct, + GetAccountBalancesStruct, + ListAccountAssetsStruct, +} from '../validation/structs'; +import { + validateOrigin, + validateRequest, + validateResponse, +} from '../validation/validators'; export class KeyringHandler implements Keyring { readonly #logger: ILogger; @@ -53,7 +63,7 @@ export class KeyringHandler implements Keyring { accountsService: AccountsService; assetsService: AssetsService; }) { - this.#logger = logger; + this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); this.#accountsService = accountsService; this.#assetsService = assetsService; } @@ -108,6 +118,8 @@ export class KeyringHandler implements Keyring { try { const account = await this.#accountsService.create(id, options); + await this.#accountsService.synchronize([account]); + return account; } catch (error: any) { this.#logger.error({ error }, 'Error creating account'); @@ -117,33 +129,79 @@ export class KeyringHandler implements Keyring { } } - async listAccountAssets(id: string): Promise { - const account = await this.#getAccountOrThrow(id); + async listAccountAssets(accountId: string): Promise { + try { + validateRequest({ accountId }, ListAccountAssetsStruct); + + await this.#getAccountOrThrow(accountId); - return this.#assetsService.listAccountAssets(account); + this.#logger.info('Listing account assets', { accountId }); + + const assets = await this.#assetsService.getByKeyringAccountId(accountId); + + const result = assets.map((asset) => asset.assetType); + + this.#logger.info('Account assets', { accountId, result }); + + validateResponse(result, ListAccountAssetsResponseStruct); + return result; + } catch (error: any) { + this.#logger.error({ error }, 'Error listing account assets'); + throw error; + } } - listAccountTransactions?( + async listAccountTransactions?( id: string, pagination: Pagination, ): Promise> { - throw new Error('Method not implemented.'); + // TODO: Implement me + return { + data: [], + next: null, + }; } - discoverAccounts?( + async discoverAccounts?( scopes: CaipChainId[], entropySource: EntropySourceId, groupIndex: number, ): Promise { - throw new Error('Method not implemented.'); + // TODO: Implement me + return []; } async getAccountBalances( - id: string, + accountId: string, assets: CaipAssetType[], ): Promise> { - const account = await this.#getAccountOrThrow(id); - return this.#assetsService.getAccountBalances(account, assets); + try { + validateRequest({ accountId, assets }, GetAccountBalancesStruct); + + await this.#getAccountOrThrow(accountId); + + const assetsList = + await this.#assetsService.getByKeyringAccountId(accountId); + const assetsOnlyRequestedAssetTypes = assetsList.filter((asset) => + assets.includes(asset.assetType), + ); + + const result = assetsOnlyRequestedAssetTypes.reduce< + Record + >((acc, asset) => { + acc[asset.assetType] = { + unit: asset.symbol, + amount: asset.uiAmount, + }; + return acc; + }, {}); + + validateResponse(result, GetAccounBalancesResponseStruct); + return result; + } catch (error: any) { + this.#logger.error({ error }, 'Error getting account balances'); + throw error; + } } resolveAccountAddress?( diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts index 30174516..32e4e237 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts @@ -3,6 +3,8 @@ import type { IStateManager } from '../state/IStateManager'; import type { UnencryptedStateValue } from '../state/State'; export class AccountsRepository { + readonly #storageKey = 'keyringAccounts'; + readonly #state: IStateManager; constructor(state: IStateManager) { @@ -15,10 +17,9 @@ export class AccountsRepository { * @returns All accounts from the state. */ async getAll(): Promise { - const accounts = - await this.#state.getKey( - 'keyringAccounts', - ); + const accounts = await this.#state.getKey< + UnencryptedStateValue['keyringAccounts'] + >(this.#storageKey); return Object.values(accounts ?? {}); } @@ -35,14 +36,14 @@ export class AccountsRepository { } async create(account: TronKeyringAccount): Promise { - await this.#state.setKey(`keyringAccounts.${account.id}`, account); + await this.#state.setKey(`${this.#storageKey}.${account.id}`, account); return account; } async delete(id: string): Promise { await Promise.all([ - this.#state.deleteKey(`keyringAccounts.${id}`), + this.#state.deleteKey(`${this.#storageKey}.${id}`), this.#state.deleteKey(`assets.${id}`), this.#state.deleteKey(`transactions.${id}`), ]); diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 7ca422f3..ab05b714 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -2,9 +2,11 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { assert, integer } from '@metamask/superstruct'; +import type { Account } from 'tronweb/lib/esm/types'; import type { AccountsRepository } from './AccountsRepository'; import type { CreateAccountOptions } from './types'; +import type { Network } from '../../constants'; import { asStrictKeyringAccount, type TronKeyringAccount, @@ -12,16 +14,34 @@ import { import { deriveTronKeypair } from '../../utils/deriveTronKeypair'; import { getLowestUnusedIndex } from '../../utils/getLowestUnusedIndex'; import { listEntropySources } from '../../utils/interface'; -import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import type { AssetsService } from '../assets/AssetsService'; +import type { ConfigProvider } from '../config'; +import type { Connection } from '../connection/Connection'; export class AccountsService { readonly #accountsRepository: AccountsRepository; + readonly #configProvider: ConfigProvider; + readonly #logger: ILogger; - constructor(accountsRepository: AccountsRepository, logger: ILogger) { + readonly #connection: Connection; + + readonly #assetsService: AssetsService; + + constructor( + accountsRepository: AccountsRepository, + configProvider: ConfigProvider, + logger: ILogger, + connection: Connection, + assetsService: AssetsService, + ) { this.#accountsRepository = accountsRepository; - this.#logger = logger; + this.#configProvider = configProvider; + this.#logger = createPrefixedLogger(logger, '[🔑 AccountsService]'); + this.#connection = connection; + this.#assetsService = assetsService; } async create( @@ -138,6 +158,36 @@ export class AccountsService { return this.#accountsRepository.delete(id); } + async fetch(account: KeyringAccount, scope: Network): Promise { + this.#logger.info('Fetching account', { address: account.address, scope }); + + const tronAccount = await this.#connection + .getConnection(scope) + .trx.getAccount(account.address); + + return tronAccount; + } + + async synchronize(accounts: KeyringAccount[]): Promise { + const scopes = this.#configProvider.get().activeNetworks; + const combinations = accounts.flatMap((account) => + scopes.map((scope) => ({ account, scope })), + ); + + await Promise.all( + combinations.map(async ({ account, scope }) => { + const tronAccount = await this.fetch(account, scope); + const assets = await this.#assetsService.fetchAssetsByAccount( + account, + scope, + tronAccount, + ); + + await this.#assetsService.saveMany(assets); + }), + ); + } + #getLowestUnusedKeyringAccountIndex( accounts: TronKeyringAccount[], entropySource: EntropySourceId, diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsSynchronizer.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsSynchronizer.ts new file mode 100644 index 00000000..4d84e77f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsSynchronizer.ts @@ -0,0 +1,22 @@ +import type { AccountsService } from './AccountsService'; +import type { TronKeyringAccount } from '../../entities'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; + +export class AccountsSynchronizer { + readonly #accountsService: AccountsService; + + readonly #logger: ILogger; + + constructor(accountsService: AccountsService, logger: ILogger) { + this.#accountsService = accountsService; + this.#logger = createPrefixedLogger(logger, '[🔄 AccountsSynchronizer]'); + } + + async synchronize(accounts?: TronKeyringAccount[]): Promise { + const accountsToSync = accounts ?? (await this.#accountsService.getAll()); + + this.#logger.info('Synchronizing accounts', accountsToSync); + + await this.#accountsService.synchronize(accountsToSync); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts new file mode 100644 index 00000000..25326c48 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts @@ -0,0 +1,31 @@ +import type { AssetEntity } from '../../entities/assets'; +import type { State, UnencryptedStateValue } from '../state/State'; + +export class AssetsRepository { + readonly #storageKey = 'assets'; + + readonly #state: State; + + constructor(state: State) { + this.#state = state; + } + + async getByAccountId(accountId: string): Promise { + return ( + (await this.#state.getKey( + `${this.#storageKey}.${accountId}`, + )) ?? [] + ); + } + + async saveMany(assets: AssetEntity[]): Promise { + return this.#state.setKey(this.#storageKey, { + ...(await this.getAll()), + ...assets, + }); + } + + async getAll(): Promise { + return (await this.#state.getKey(this.#storageKey)) ?? []; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 6f95bd64..5031f76b 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,4 +1,10 @@ -import type { Balance } from '@metamask/keyring-api'; +import { + KeyringEvent, + type AccountBalancesUpdatedEvent, + type AccountAssetListUpdatedEvent, + type KeyringAccount, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { AssetMetadata, FungibleAssetMetadata, @@ -6,145 +12,112 @@ import type { } from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; -import { uniq } from 'lodash'; +import { cloneDeep } from 'lodash'; +import type { Account } from 'tronweb/lib/esm/types'; -import type { TronWebClient } from '../../clients/tronweb/TronWeb'; -import type { TronKeyringAccount } from '../../entities'; -import type { ILogger } from '../../utils/logger'; -import type { ConfigProvider } from '../config'; +import type { AssetsRepository } from './AssetsRepository'; import type { NativeCaipAssetType, NftCaipAssetType, TokenCaipAssetType, } from './types'; +import { Network } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import type { Connection } from '../connection/Connection'; import type { State, UnencryptedStateValue } from '../state/State'; export class AssetsService { readonly #logger: ILogger; - readonly #loggerPrefix = '[🪙 AssetsService]'; + readonly #assetsRepository: AssetsRepository; - // readonly #configProvider: ConfigProvider; + readonly #connection: Connection; - // readonly #state: State; - - readonly #tronWebClient: TronWebClient; + readonly #state: State; constructor({ logger, - // configProvider, - // state, - tronWebClient, + assetsRepository, + connection, + state, }: { logger: ILogger; - configProvider: ConfigProvider; + assetsRepository: AssetsRepository; + connection: Connection; state: State; - tronWebClient: TronWebClient; }) { - this.#logger = logger; - // this.#configProvider = configProvider; - // this.#state = state; - this.#tronWebClient = tronWebClient; - } - - async #listAddressNativeAssets( - address: string, - ): Promise { - const result = await this.#tronWebClient.getAccount(address); - - console.log('result', result.assetV2); - - return ['tron:728126428/slip44:195']; - } - - async #listAddressTokenAssets( - _address: string, - ): Promise { - return []; // TODO: Implement me - } - - async #listAddressNftAssets(_address: string): Promise { - return []; // TODO: Implement me - } - - async listAccountAssets( - account: TronKeyringAccount, - ): Promise { - this.#logger.log( - this.#loggerPrefix, - 'Fetching all assets for account', - account, - ); - - const accountAddress = account.address; - - const [nativeAssetsIds, tokenAssetsIds, nftAssetsIds] = await Promise.all([ - this.#listAddressNativeAssets(accountAddress), - this.#listAddressTokenAssets(accountAddress), - this.#listAddressNftAssets(accountAddress), - ]); - - return uniq([...nativeAssetsIds, ...tokenAssetsIds, ...nftAssetsIds]); - } - - async getAccountBalances( - account: TronKeyringAccount, - assetTypes: CaipAssetType[], - ): Promise> { - return { - 'tron:728126428/slip44:195': { - unit: 'TRX', - amount: '10000', - }, - }; + this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); + this.#assetsRepository = assetsRepository; + this.#connection = connection; + this.#state = state; } async getAssetsMetadata( assetTypes: CaipAssetType[], ): Promise> { - this.#logger.log( - this.#loggerPrefix, - 'Fetching metadata for assets', - assetTypes, - ); + this.#logger.info('Fetching metadata for assets', assetTypes); - const { nativeAssetTypes, tokenAssetTypes, nftAssetTypes } = - this.#splitAssetsByType(assetTypes); + const { + nativeAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + nftAssetTypes, + } = this.#splitAssetsByType(assetTypes); - const [nativeTokensMetadata, tokensMetadata, nftMetadata] = - await Promise.all([ - this.getNativeTokensMetadata(nativeAssetTypes), - this.getTokensMetadata(tokenAssetTypes), - this.getNftsMetadata(nftAssetTypes), - ]); + const [ + nativeTokensMetadata, + trc10TokensMetadata, + trc20TokensMetadata, + nftMetadata, + ] = await Promise.all([ + this.#getNativeTokensMetadata(nativeAssetTypes), + this.#getTRC10TokensMetadata(tokenTrc10AssetTypes, Network.Mainnet), + this.#getTRC20TokensMetadata(tokenTrc20AssetTypes, Network.Mainnet), + this.#getNftsMetadata(nftAssetTypes), + ]); - return { + const result = { ...nativeTokensMetadata, - ...tokensMetadata, + ...trc10TokensMetadata, + ...trc20TokensMetadata, ...nftMetadata, }; + + this.#logger.info('Resolved assets metadata', { assetTypes, result }); + console.log('Resolved assets metadata', JSON.stringify(result, null, 2)); + + return result; } #splitAssetsByType(assetTypes: CaipAssetType[]): { nativeAssetTypes: NativeCaipAssetType[]; - tokenAssetTypes: TokenCaipAssetType[]; + tokenTrc10AssetTypes: TokenCaipAssetType[]; + tokenTrc20AssetTypes: TokenCaipAssetType[]; nftAssetTypes: NftCaipAssetType[]; } { const nativeAssetTypes = assetTypes.filter((assetType) => assetType.endsWith('slip44:195'), ) as NativeCaipAssetType[]; - const tokenAssetTypes = assetTypes.filter( - (assetType) => - assetType.includes('/trc10:') || assetType.includes('/trc20:'), + const tokenTrc10AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc10:'), + ) as TokenCaipAssetType[]; + const tokenTrc20AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc20:'), ) as TokenCaipAssetType[]; const nftAssetTypes = assetTypes.filter((assetType) => assetType.includes('/trc721'), ) as NftCaipAssetType[]; - return { nativeAssetTypes, tokenAssetTypes, nftAssetTypes }; + return { + nativeAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + nftAssetTypes, + }; } - getNativeTokensMetadata( + #getNativeTokensMetadata( assetTypes: NativeCaipAssetType[], ): Record { const nativeTokensMetadata: Record< @@ -155,7 +128,7 @@ export class AssetsService { for (const assetType of assetTypes) { // const { chainId } = parseCaipAssetType(assetType); nativeTokensMetadata[assetType] = { - name: 'TRON', + name: 'TRX', symbol: 'TRX', fungible: true as const, // iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, @@ -163,7 +136,7 @@ export class AssetsService { 'https://altcoinsbox.com/wp-content/uploads/2023/01/tron-coin-logo-300x300.webp', units: [ { - name: 'TRON', + name: 'TRX', symbol: 'TRX', decimals: 9, }, @@ -174,16 +147,51 @@ export class AssetsService { return nativeTokensMetadata; } - async getTokensMetadata( + async #getTRC10TokensMetadata( + assetTypes: TokenCaipAssetType[], + scope: Network, + ): Promise> { + const connection = this.#connection.getConnection(scope); + const metadata = await Promise.all( + assetTypes.map(async (assetType) => { + const { assetReference } = parseCaipAssetType(assetType); + console.log('assetReference', assetReference); + const tokenMetadata = await connection.trx.getTokenByID(assetReference); + + return { + [assetType]: { + name: tokenMetadata.name, + symbol: tokenMetadata.abbr, + fungible: true as const, + iconUrl: `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/assets/${assetReference}/logo.png`, + units: [ + { + decimals: tokenMetadata.precision, + symbol: tokenMetadata.abbr, + }, + ], + }, + }; + }), + ); + + return metadata.reduce((acc, curr) => { + return { ...acc, ...curr }; + }, {}); + } + + async #getTRC20TokensMetadata( assetTypes: TokenCaipAssetType[], + scope: Network, ): Promise> { const metadata = await Promise.all( assetTypes.map(async (assetType) => { const { assetReference } = parseCaipAssetType(assetType); - const contract = await this.#tronWebClient.tronWeb - .contract() - .at(assetReference); + const contract = await this.#connection + .getConnection(scope) + .trx.getContract(assetReference); + const name = await contract.name(); const symbol = await contract.symbol(); const decimals = await contract.decimals(); @@ -193,7 +201,7 @@ export class AssetsService { name, symbol, fungible: true as const, - iconUrl: '', + iconUrl: `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/assets/${assetReference}/logo.png`, units: [{ decimals, symbol }], }, }; @@ -205,9 +213,274 @@ export class AssetsService { }, {}); } - async getNftsMetadata( + async #getNftsMetadata( _assetTypes: NftCaipAssetType[], ): Promise> { return {}; // TODO: Implement me } + + async fetchAssetsByAccount( + account: KeyringAccount, + scope: Network, + tronAccount: Account, + ): Promise { + const [nativeAsset, trc10Assets] = await Promise.allSettled([ + this.#getNativeAsset(account, scope, tronAccount), + this.#fetchTRC10Assets(account, scope, tronAccount), + this.#fetchTRC20Assets(account, scope), + ]); + + return [ + ...(nativeAsset.status === 'fulfilled' ? [nativeAsset.value] : []), + ...(trc10Assets.status === 'fulfilled' ? trc10Assets.value : []), + ]; + } + + #getNativeAsset( + account: KeyringAccount, + scope: Network, + tronAccount: Account, + ): AssetEntity { + const asset: AssetEntity = { + assetType: `${scope}/slip44:195` as NativeCaipAssetType, + keyringAccountId: account.id, + network: scope, + address: account.address, + symbol: 'TRX', + decimals: 9, + rawAmount: tronAccount?.balance?.toString(), + uiAmount: tronAccount?.balance?.toString(), + }; + + return asset; + } + + async #fetchTRC10Assets( + account: KeyringAccount, + scope: Network, + tronAccount: Account, + ): Promise { + const connection = this.#connection.getConnection(scope); + + const responses = await Promise.allSettled( + tronAccount.assetV2.map(async (asset): Promise => { + if (!asset.key || !asset.value) { + throw new Error('Asset ID is required'); + } + + const metadata = await connection.trx.getTokenByID(asset.key); + + return { + assetType: `${scope}/trc10:${asset.key}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + symbol: metadata.abbr, + decimals: metadata.precision, + mint: metadata.owner_address, + rawAmount: asset.value.toString(), + uiAmount: asset.value.toString(), + pubkey: asset.key.toString(), + }; + }), + ); + + return responses.flatMap((response) => + response.status === 'fulfilled' ? response.value : [], + ); + } + + async #fetchTRC20Assets( + _account: KeyringAccount, + _scope: Network, + ): Promise { + return []; // TODO: Implement me + } + + /** + * Checks if the asset has changed compared to passed assets lookup. + * + * @param asset - The asset to check. + * @param assetsLookup - The lookup table to check against. + * @returns True if the asset has changed, false otherwise. + */ + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { + const savedAsset = assetsLookup.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + if (!savedAsset) { + return true; + } + + return savedAsset.rawAmount !== asset.rawAmount; + } + + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Saving assets', assets); + + const savedAssets = await this.getAll(); + + // Update the state atomically + await this.#state.update((stateValue) => { + const newState = cloneDeep(stateValue); + for (const asset of assets) { + const { keyringAccountId } = asset; + const accountAssets = cloneDeep( + newState.assets[keyringAccountId] ?? [], + ); + + // Avoid duplicates. If same asset is already saved, override it. + const existingAssetIndex = accountAssets.findIndex( + (item) => + item.assetType === asset.assetType && + item.keyringAccountId === asset.keyringAccountId, + ); + + if (existingAssetIndex === -1) { + accountAssets.push(asset); + } else { + accountAssets[existingAssetIndex] = asset; + } + + newState.assets[keyringAccountId] = accountAssets; + } + return newState; + }); + + this.#logger.info('Saved assets', { savedAssets }); + + const hasZeroRawAmount = (asset: AssetEntity): boolean => + asset.rawAmount === '0'; + const hasNonZeroRawAmount = (asset: AssetEntity): boolean => + !hasZeroRawAmount(asset); + + // Notify the extension about the new assets in a single event + const isNew = (asset: AssetEntity): boolean => + !savedAssets.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + const wasSavedWithZeroRawAmount = (asset: AssetEntity): boolean => { + const savedAsset = savedAssets.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + return savedAsset !== undefined && hasZeroRawAmount(savedAsset); + }; + + const assetListUpdatedPayload = assets.reduce< + AccountAssetListUpdatedEvent['params']['assets'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [ + ...(acc[asset.keyringAccountId]?.added ?? []), + ...((isNew(asset) || wasSavedWithZeroRawAmount(asset)) && + hasNonZeroRawAmount(asset) + ? [asset.assetType] + : []), + ], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + ...(hasZeroRawAmount(asset) ? [asset.assetType] : []), + ], + }, + }), + {}, + ); + + const isEmptyAccountAssetListUpdatedPayload = Object.values( + assetListUpdatedPayload, + ) + .map((item) => item.added.length + item.removed.length) + .every((item) => item === 0); + + if (isEmptyAccountAssetListUpdatedPayload) { + this.#logger.info('No account asset list updated', { + assetListUpdatedPayload, + }); + } else { + this.#logger.info('Updating account asset list', { + isEmptyAccountAssetListUpdatedPayload, + assetListUpdatedPayload, + }); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + + this.#logger.info('Account asset list updated', { + assetListUpdatedPayload, + }); + } + + // Notify the extension about the changed balances in a single event + + const hasChanged = (asset: AssetEntity): boolean => + AssetsService.hasChanged(asset, savedAssets); + + const balancesUpdatedPayload = assets + .filter(hasNonZeroRawAmount) + .filter(hasChanged) + .reduce( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + ...(acc[asset.keyringAccountId] ?? {}), + [asset.assetType]: { + unit: asset.symbol, + amount: asset.uiAmount, + }, + }, + }), + {}, + ); + const isEmptyAccountBalancesUpdatedPayload = Object.values( + balancesUpdatedPayload, + ) + .map((item) => Object.keys(item).length) + .every((item) => item === 0); + + if (isEmptyAccountBalancesUpdatedPayload) { + this.#logger.info('No account balances updated', { + balancesUpdatedPayload, + }); + } else { + this.#logger.info('Updating account balances', { + balancesUpdatedPayload, + }); + console.log( + 'balancesUpdatedPayload', + JSON.stringify(balancesUpdatedPayload, null, 2), + ); + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + + this.#logger.info('Account balances updated', { + balancesUpdatedPayload, + }); + } + } + + async getAll(): Promise { + const assetsByAccount = + (await this.#state.getKey('assets')) ?? + {}; + + return Object.values(assetsByAccount).flat(); + } + + async getByKeyringAccountId( + keyringAccountId: string, + ): Promise { + return this.#assetsRepository.getByAccountId(keyringAccountId); + } } diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index 6be002fb..1978a169 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -15,7 +15,7 @@ import { UrlStruct } from '../../validation/structs'; const ENVIRONMENT_TO_ACTIVE_NETWORKS = { production: [Network.Mainnet], - local: [Network.Mainnet, Network.Nile, Network.Shasta], + local: [Network.Mainnet], test: [Network.Localnet], }; diff --git a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts new file mode 100644 index 00000000..098bff9a --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts @@ -0,0 +1,51 @@ +import { assert } from '@metamask/superstruct'; +import { TronWeb, providers } from 'tronweb'; + +import type { Network } from '../../constants'; +import { NetworkStruct } from '../../validation/structs'; +import type { ConfigProvider } from '../config'; + +export class Connection { + readonly #networkCaip2IdToConnection: Map = new Map(); + + readonly #configProvider: ConfigProvider; + + constructor(configProvider: ConfigProvider) { + this.#configProvider = configProvider; + } + + #createConnection(network: Network): TronWeb { + const config = this.#configProvider.getNetworkBy('caip2Id', network); + const rpcUrl = config.rpcUrls[0] ?? ''; + const fullNode = new providers.HttpProvider(rpcUrl); + const solidityNode = new providers.HttpProvider(rpcUrl); + const eventServer = new providers.HttpProvider(rpcUrl); + + const headers = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + 'TRON-PRO-API-KEY': '6e4cbe0c-0bfb-4ea0-bdf8-526c0664801a', + }; + + const connection = new TronWeb({ + fullNode, + solidityNode, + eventServer, + }); + + connection.setHeader(headers); + + this.#networkCaip2IdToConnection.set(network, connection); + + return connection; + } + + getConnection(network: Network): TronWeb { + assert(network, NetworkStruct); + return ( + this.#networkCaip2IdToConnection.get(network) ?? + this.#createConnection(network) + ); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.ts b/merged-packages/tron-wallet-snap/src/services/state/State.ts index a74a0789..0c8e31b6 100644 --- a/merged-packages/tron-wallet-snap/src/services/state/State.ts +++ b/merged-packages/tron-wallet-snap/src/services/state/State.ts @@ -1,10 +1,10 @@ -import type { Balance, Transaction } from '@metamask/keyring-api'; -import type { CaipAssetType } from '@metamask/utils'; +import type { Transaction } from '@metamask/keyring-api'; import { unset } from 'lodash'; import type { IStateManager } from './IStateManager'; import type { SpotPrices } from '../../clients/price-api/types'; import type { TronKeyringAccount } from '../../entities'; +import type { AssetEntity } from '../../entities/assets'; import { safeMerge } from '../../utils/safeMerge'; import { deserialize } from '../../utils/serialization/deserialize'; import { serialize } from '../../utils/serialization/serialize'; @@ -14,7 +14,7 @@ export type AccountId = string; export type UnencryptedStateValue = { keyringAccounts: Record; - assets: Record>; + assets: Record; tokenPrices: SpotPrices; transactions: Record; }; diff --git a/merged-packages/tron-wallet-snap/src/utils/logger.ts b/merged-packages/tron-wallet-snap/src/utils/logger.ts index ebbf3a7f..33acd8f0 100644 --- a/merged-packages/tron-wallet-snap/src/utils/logger.ts +++ b/merged-packages/tron-wallet-snap/src/utils/logger.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-empty-function */ + /** * A simple logger utility that provides methods for logging messages at different levels. * For now, it's just a wrapper around console. @@ -47,4 +49,29 @@ const logger: ILogger = { error: withNoopInProduction(withErrorLogging(console.error)), }; +export const noOpLogger: ILogger = { + log: () => {}, + info: () => {}, + warn: () => {}, + debug: () => {}, + error: () => {}, +}; + +export const createPrefixedLogger = ( + _logger: ILogger, + prefix: string, +): ILogger => { + return new Proxy(_logger, { + get(target, prop: keyof ILogger): any { + const method = target[prop]; + if (typeof method === 'function') { + return (message: string, ...args: any[]) => { + return method.call(target, prefix, message, ...args); + }; + } + return method; + }, + }); +}; + export default logger; From 6b70e6070793c35d77cac5093621149bed888b13 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Thu, 31 Jul 2025 10:38:04 +0200 Subject: [PATCH 010/238] fix: comments --- merged-packages/tron-wallet-snap/snap.config.ts | 1 + merged-packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/services/accounts/AccountsService.ts | 12 ++++++++---- .../src/services/assets/AssetsService.ts | 10 +++------- .../src/services/config/ConfigProvider.ts | 8 ++++++++ .../src/services/connection/Connection.ts | 2 +- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.config.ts b/merged-packages/tron-wallet-snap/snap.config.ts index 4ef59311..fcc00437 100644 --- a/merged-packages/tron-wallet-snap/snap.config.ts +++ b/merged-packages/tron-wallet-snap/snap.config.ts @@ -28,6 +28,7 @@ const config: SnapConfig = { process.env.SECURITY_ALERTS_API_BASE_URL ?? '', NFT_API_BASE_URL: process.env.NFT_API_BASE_URL ?? '', LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL ?? '', + TRON_API_KEY: process.env.TRON_API_KEY ?? '', }, polyfills: true, }; diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index cead738b..f0d5c5b0 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "uPsIeVFJSw2uNClvPEyzhIZo463Y8aSKM/nkzm2sC54=", + "shasum": "xByKIzeUj0r7mgXdLpMyd+4Y78rRQgGUKTbMauIz25M=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index ab05b714..0365059b 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -174,18 +174,22 @@ export class AccountsService { scopes.map((scope) => ({ account, scope })), ); - await Promise.all( + const responses = await Promise.allSettled( combinations.map(async ({ account, scope }) => { const tronAccount = await this.fetch(account, scope); - const assets = await this.#assetsService.fetchAssetsByAccount( + return this.#assetsService.fetchAssetsByAccount( account, scope, tronAccount, ); - - await this.#assetsService.saveMany(assets); }), ); + + const assets = responses.flatMap((response) => + response.status === 'fulfilled' ? response.value : [], + ); + + await this.#assetsService.saveMany(assets); } #getLowestUnusedKeyringAccountIndex( diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 5031f76b..89a63a96 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -85,7 +85,6 @@ export class AssetsService { }; this.#logger.info('Resolved assets metadata', { assetTypes, result }); - console.log('Resolved assets metadata', JSON.stringify(result, null, 2)); return result; } @@ -155,7 +154,6 @@ export class AssetsService { const metadata = await Promise.all( assetTypes.map(async (assetType) => { const { assetReference } = parseCaipAssetType(assetType); - console.log('assetReference', assetReference); const tokenMetadata = await connection.trx.getTokenByID(assetReference); return { @@ -224,7 +222,7 @@ export class AssetsService { scope: Network, tronAccount: Account, ): Promise { - const [nativeAsset, trc10Assets] = await Promise.allSettled([ + const [nativeAsset, trc10Assets, trc20Assets] = await Promise.allSettled([ this.#getNativeAsset(account, scope, tronAccount), this.#fetchTRC10Assets(account, scope, tronAccount), this.#fetchTRC20Assets(account, scope), @@ -233,6 +231,7 @@ export class AssetsService { return [ ...(nativeAsset.status === 'fulfilled' ? [nativeAsset.value] : []), ...(trc10Assets.status === 'fulfilled' ? trc10Assets.value : []), + ...(trc20Assets.status === 'fulfilled' ? trc20Assets.value : []), ]; } @@ -456,10 +455,7 @@ export class AssetsService { this.#logger.info('Updating account balances', { balancesUpdatedPayload, }); - console.log( - 'balancesUpdatedPayload', - JSON.stringify(balancesUpdatedPayload, null, 2), - ); + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { balances: balancesUpdatedPayload, }); diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index 1978a169..9c1a00a1 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -46,6 +46,7 @@ const EnvStruct = object({ SECURITY_ALERTS_API_BASE_URL: UrlStruct, NFT_API_BASE_URL: UrlStruct, LOCAL_API_BASE_URL: string(), + TRON_API_KEY: string(), }); export type Env = Infer; @@ -88,6 +89,9 @@ export type Config = { getNftMetadata: number; }; }; + tronApi: { + apiKey: string; + }; }; /** @@ -127,6 +131,7 @@ export class ConfigProvider { SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL, NFT_API_BASE_URL: process.env.NFT_API_BASE_URL, LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL, + TRON_API_KEY: process.env.TRON_API_KEY, }; // Validate and parse them before returning @@ -200,6 +205,9 @@ export class ConfigProvider { getNftMetadata: Duration.Minute, }, }, + tronApi: { + apiKey: environment.TRON_API_KEY, + }, }; } diff --git a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts index 098bff9a..48cd13f8 100644 --- a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts +++ b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts @@ -25,7 +25,7 @@ export class Connection { 'Content-Type': 'application/json', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Origin': '*', - 'TRON-PRO-API-KEY': '6e4cbe0c-0bfb-4ea0-bdf8-526c0664801a', + 'TRON-PRO-API-KEY': this.#configProvider.get().tronApi.apiKey, }; const connection = new TronWeb({ From 23a280e07f7f881e99a84519a165eb5d1aba4632 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 26 Aug 2025 16:46:45 +0100 Subject: [PATCH 011/238] feat: fully support TRX + token assets balances --- merged-packages/tron-wallet-snap/.env.example | 14 +- merged-packages/tron-wallet-snap/package.json | 8 +- .../tron-wallet-snap/snap.config.ts | 12 +- .../tron-wallet-snap/snap.manifest.json | 4 +- .../src/clients/snap/SnapClient.ts | 129 ++++ .../src/clients/tron-http/TronHttpClient.ts | 344 +++++++++ .../src/clients/tron-http/index.ts | 2 + .../src/clients/tron-http/types.ts | 93 +++ .../src/clients/trongrid/TrongridApiClient.ts | 84 +++ .../src/clients/trongrid/types.ts | 118 +++ .../tron-wallet-snap/src/constants/index.ts | 2 + .../tron-wallet-snap/src/context.ts | 65 +- .../tron-wallet-snap/src/entities/assets.ts | 2 +- .../tron-wallet-snap/src/handlers/assets.ts | 52 +- .../tron-wallet-snap/src/handlers/cronjob.ts | 64 +- .../tron-wallet-snap/src/handlers/keyring.ts | 18 +- .../src/handlers/lifecycle.ts | 42 ++ merged-packages/tron-wallet-snap/src/index.ts | 10 +- .../src/services/accounts/AccountsService.ts | 85 ++- .../services/accounts/AccountsSynchronizer.ts | 22 - .../src/services/assets/AssetsService.ts | 688 ++++++++++++++---- .../src/services/config/ConfigProvider.ts | 46 +- .../src/services/connection/Connection.ts | 65 +- .../transactions/TransactionsService.ts | 9 - .../tron-wallet-snap/src/utils/interface.ts | 125 ---- 25 files changed, 1657 insertions(+), 446 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/tron-http/index.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts delete mode 100644 merged-packages/tron-wallet-snap/src/services/accounts/AccountsSynchronizer.ts delete mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts delete mode 100644 merged-packages/tron-wallet-snap/src/utils/interface.ts diff --git a/merged-packages/tron-wallet-snap/.env.example b/merged-packages/tron-wallet-snap/.env.example index 6f746f05..8209edf7 100644 --- a/merged-packages/tron-wallet-snap/.env.example +++ b/merged-packages/tron-wallet-snap/.env.example @@ -19,4 +19,16 @@ STATIC_API_BASE_URL=https://static.cx.metamask.io SECURITY_ALERTS_API_BASE_URL=https://security-alerts.api.cx.metamask.io NFT_API_BASE_URL=https://nft.api.cx.metamask.io -LOCAL_API_BASE_URL=http://localhost:8899 \ No newline at end of file +LOCAL_API_BASE_URL=http://localhost:8899 + +# TronGrid API Base URLs +TRONGRID_BASE_URL_MAINNET=https://api.trongrid.io +TRONGRID_BASE_URL_NILE=https://nile.api.trongrid.io +TRONGRID_BASE_URL_SHASTA=https://shasta.api.trongrid.io +TRONGRID_BASE_URL_LOCALNET=https://api.trongrid.io + +# Tron HTTP API Base URLs +TRON_HTTP_BASE_URL_MAINNET=https://api.trongrid.io +TRON_HTTP_BASE_URL_NILE=https://nile.trongrid.io +TRON_HTTP_BASE_URL_SHASTA=https://shasta.trongrid.io +TRON_HTTP_BASE_URL_LOCALNET=https://api.trongrid.io diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 852a837e..4d1f0205 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -28,6 +28,7 @@ "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", "lint:misc": "prettier '**/*.json' '**/*.md' --check", "lint:types": "tsc --noEmit", + "format": "prettier '**/*.ts' '**/*.tsx' --write", "prepublishOnly": "mm-snap manifest", "serve": "mm-snap serve", "start": "concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", @@ -40,7 +41,7 @@ "@metamask/keyring-snap-sdk": "^4.0.0", "@metamask/snaps-cli": "^8.1.0", "@metamask/snaps-jest": "^9.2.0", - "@metamask/snaps-sdk": "^9.2.0", + "@metamask/snaps-sdk": "^9.3.0", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.4.2", "@types/jest": "^30.0.0", @@ -52,14 +53,11 @@ "jest-transform-stub": "2.0.0", "lodash": "^4.17.21", "prettier": "^3.5.3", - "tronweb": "6.0.3", + "tronweb": "patch:tronweb@npm%3A6.0.4#~/.yarn/patches/tronweb-npm-6.0.4-2b661b7651.patch", "ts-jest": "^29.4.0" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" - }, - "dependencies": { - "tronweb": "^6.0.3" } } diff --git a/merged-packages/tron-wallet-snap/snap.config.ts b/merged-packages/tron-wallet-snap/snap.config.ts index fcc00437..93d2660d 100644 --- a/merged-packages/tron-wallet-snap/snap.config.ts +++ b/merged-packages/tron-wallet-snap/snap.config.ts @@ -28,7 +28,17 @@ const config: SnapConfig = { process.env.SECURITY_ALERTS_API_BASE_URL ?? '', NFT_API_BASE_URL: process.env.NFT_API_BASE_URL ?? '', LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL ?? '', - TRON_API_KEY: process.env.TRON_API_KEY ?? '', + // TronGrid API + TRONGRID_BASE_URL_MAINNET: process.env.TRONGRID_BASE_URL_MAINNET ?? '', + TRONGRID_BASE_URL_NILE: process.env.TRONGRID_BASE_URL_NILE ?? '', + TRONGRID_BASE_URL_SHASTA: process.env.TRONGRID_BASE_URL_SHASTA ?? '', + TRONGRID_BASE_URL_LOCALNET: process.env.TRONGRID_BASE_URL_LOCALNET ?? '', + TRONGRID_API_KEY: process.env.TRONGRID_API_KEY ?? '', + // Tron HTTP API + TRON_HTTP_BASE_URL_MAINNET: process.env.TRON_HTTP_BASE_URL_MAINNET ?? '', + TRON_HTTP_BASE_URL_NILE: process.env.TRON_HTTP_BASE_URL_NILE ?? '', + TRON_HTTP_BASE_URL_SHASTA: process.env.TRON_HTTP_BASE_URL_SHASTA ?? '', + TRON_HTTP_BASE_URL_LOCALNET: process.env.TRON_HTTP_BASE_URL_LOCALNET ?? '', }, polyfills: true, }; diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index f0d5c5b0..565ef343 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "xByKIzeUj0r7mgXdLpMyd+4Y78rRQgGUKTbMauIz25M=", + "shasum": "8q9+CzdymnuXlLShYgcMCdhA1Ulf7BBxacfsu+V5ZpU=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -54,6 +54,6 @@ "scopes": ["tron:728126428", "tron:3448148188", "tron:2494104990"] } }, - "platformVersion": "9.2.0", + "platformVersion": "9.3.0", "manifestVersion": "0.1" } diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts new file mode 100644 index 00000000..044d3aef --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts @@ -0,0 +1,129 @@ +import type { + DialogResult, + EntropySource, + GetClientStatusResult, + GetInterfaceStateResult, + Json, + ResolveInterfaceResult, +} from '@metamask/snaps-sdk'; + +import type { Preferences } from '../../types/snap'; + +/** + * Client for interacting with the Snap API. + * Provides methods for managing interfaces, dialogs, preferences, and background events. + */ +export class SnapClient { + /** + * Gets the state of an interactive interface by its ID. + * + * @param id - The ID for the interface to update. + * @returns An object containing the state of the interface. + */ + async getInterfaceState(id: string): Promise { + return snap.request({ + method: 'snap_getInterfaceState', + params: { + id, + }, + }); + } + + /** + * Resolve a dialog using the provided ID. + * + * @param id - The ID for the interface to update. + * @param value - The result to resolve the interface with. + * @returns An object containing the state of the interface. + */ + async resolveInterface( + id: string, + value: Json, + ): Promise { + return snap.request({ + method: 'snap_resolveInterface', + params: { + id, + value, + }, + }); + } + + /** + * Shows a dialog using the provided ID. + * + * @param id - The ID for the dialog. + * @returns A promise that resolves to a string. + */ + async showDialog(id: string): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + id, + }, + }); + } + + /** + * Get preferences from snap. + * + * @returns A promise that resolves to snap preferences. + */ + async getPreferences(): Promise { + return snap.request({ + method: 'snap_getPreferences', + }) as Promise; + } + + /** + * Retrieves the client status (locked/unlocked) in this case from MM. + * + * @returns An object containing the status. + */ + async getClientStatus(): Promise { + return await snap.request({ + method: 'snap_getClientStatus', + }); + } + + /** + * Schedules a background event. + * + * @param options - The options for the background event. + * @param options.method - The method to call. + * @param options.params - The params to pass to the method. + * @param options.duration - The duration to wait before the event is scheduled. + * @returns A promise that resolves to a string. + */ + async scheduleBackgroundEvent({ + method, + params = {}, + duration, + }: { + method: string; + params?: Record; + duration: string; + }): Promise { + return await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration, + request: { + method, + params, + }, + }, + }); + } + + /** + * List all entropy sources. + * + * @returns An array of entropy sources. + */ + async listEntropySources(): Promise { + return await snap.request({ + method: 'snap_listEntropySources', + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts new file mode 100644 index 00000000..1f5c024c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts @@ -0,0 +1,344 @@ +import type { + TRC10TokenInfo, + TRC10TokenMetadata, + TRC20TokenMetadata, + TriggerConstantContractRequest, + TriggerConstantContractResponse, + TronContract, +} from './types'; +import type { Network } from '../../constants'; +import { NULL_ADDRESS } from '../../constants'; +import type { ConfigProvider } from '../../services/config'; + +/** + * Client for Tron JSON-RPC HTTP endpoints (not the REST API) + * Handles contract interactions, constant contract calls, etc. + */ +export class TronHttpClient { + readonly #apiKey?: string; + + readonly #clients: Map< + Network, + { + baseUrl: string; + headers: Record; + } + > = new Map(); + + constructor({ configProvider }: { configProvider: ConfigProvider }) { + const { apiKey } = configProvider.get().trongridApi; + const { baseUrls } = configProvider.get().tronHttpApi; + this.#apiKey = apiKey; + + // Initialize clients for all networks + Object.entries(baseUrls).forEach(([network, baseUrl]) => { + const headers: Record = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + }; + + if (this.#apiKey) { + headers['TRON-PRO-API-KEY'] = this.#apiKey; + } + + this.#clients.set(network as Network, { baseUrl, headers }); + }); + } + + /** + * Get contract information by address + * + * @param contractAddress - The contract address + * @param network - The network to query + * @returns Promise - Contract information + */ + async getContract( + contractAddress: string, + network: Network, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = `${baseUrl}/wallet/getcontract`; + + const body = JSON.stringify({ + value: contractAddress, + visible: true, + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const contractData: TronContract = await response.json(); + return contractData; + } + + /** + * Call a constant function on a TRC20 contract + * + * @param contractAddress - The contract address + * @param functionSelector - The function selector (e.g., 'name()', 'symbol()', 'decimals()') + * @param network - The network to query + * @returns Promise - The function result + */ + async triggerConstantContract( + contractAddress: string, + functionSelector: string, + network: Network, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = `${baseUrl}/wallet/triggerconstantcontract`; + + const requestBody: TriggerConstantContractRequest = { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + owner_address: NULL_ADDRESS, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + contract_address: contractAddress, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + function_selector: functionSelector, + parameter: '', + visible: true, + }; + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseData: TriggerConstantContractResponse = await response.json(); + + if (!responseData.result?.result) { + throw new Error(`Contract call failed: ${JSON.stringify(responseData)}`); + } + + return responseData.constant_result; + } + + /** + * Decode hex string to UTF-8 string (for name and symbol) + * + * @param hexString - The hex string to decode + * @returns string - The decoded string + */ + #decodeHexToString(hexString: string): string { + if (!hexString) return ''; + + try { + // Remove '0x' prefix if present + const cleanHex = hexString.startsWith('0x') + ? hexString.slice(2) + : hexString; + + // ABI-encoded string format: + // - First 32 bytes (64 hex chars): offset to data (usually 0x20 = 32) + // - Next 32 bytes (64 hex chars): length of string + // - Next N bytes: the actual string data + + const lengthHex = cleanHex.slice(64, 128); // bytes 32-64 + const length = parseInt(lengthHex, 16); // length in bytes + + if (length === 0) return ''; + + const dataStartIndex = 128; // Start after offset + length (64 + 64) + const dataEndIndex = dataStartIndex + length * 2; // length * 2 because each byte = 2 hex chars + const dataHex = cleanHex.slice(dataStartIndex, dataEndIndex); + + // Convert hex to string + let result = ''; + for (let index = 0; index < dataHex.length; index += 2) { + const byte = dataHex.substr(index, 2); + const charCode = parseInt(byte, 16); + if (charCode > 0) { + // Only add non-null characters + result += String.fromCharCode(charCode); + } + } + + return result.trim(); + } catch (error) { + console.error('Error decoding hex string:', error, 'hex:', hexString); + return ''; + } + } + + /** + * Decode hex string to number (for decimals) + * + * @param hexString - The hex string to decode + * @returns number - The decoded number + */ + #decodeHexToNumber(hexString: string): number { + if (!hexString) return 0; + + // Remove '0x' prefix if present + const cleanHex = hexString.startsWith('0x') + ? hexString.slice(2) + : hexString; + + return parseInt(cleanHex, 16); + } + + /** + * Get TRC20 token metadata (name, symbol, decimals) + * + * @param contractAddress - The TRC20 contract address + * @param network - The network to query + * @returns Promise - Token metadata + */ + async getTRC20TokenMetadata( + contractAddress: string, + network: Network, + ): Promise { + const [nameResult, symbolResult, decimalsResult] = await Promise.all([ + this.triggerConstantContract(contractAddress, 'name()', network), + this.triggerConstantContract(contractAddress, 'symbol()', network), + this.triggerConstantContract(contractAddress, 'decimals()', network), + ]); + + const name = this.#decodeHexToString(nameResult[0] ?? ''); + const symbol = this.#decodeHexToString(symbolResult[0] ?? ''); + const decimals = this.#decodeHexToNumber(decimalsResult[0] ?? '0'); + + return { + name, + symbol, + decimals, + }; + } + + /** + * Get TRC10 token information by ID + * + * @param tokenId - The TRC10 token ID + * @param network - The network to query + * @returns Promise - Token information + */ + async getTRC10TokenById( + tokenId: string, + network: Network, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = `${baseUrl}/wallet/getassetissuebyid`; + + const body = JSON.stringify({ + value: tokenId, + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const tokenData: TRC10TokenInfo = await response.json(); + return tokenData; + } + + /** + * Get TRC10 token metadata (name, symbol, decimals) + * + * @param tokenId - The TRC10 token ID + * @param network - The network to query + * @returns Promise - Token metadata + */ + async getTRC10TokenMetadata( + tokenId: string, + network: Network, + ): Promise { + const tokenInfo = await this.getTRC10TokenById(tokenId, network); + + return { + name: tokenInfo.name, + symbol: tokenInfo.abbr, + decimals: tokenInfo.precision, + }; + } + + /** + * Get contract information for all configured networks + * + * @param contractAddress - The contract address + * @returns Promise> - Contract information for all networks + */ + async getContractForAllNetworks( + contractAddress: string, + ): Promise> { + const results: Record = {} as Record< + Network, + TronContract + >; + + for (const [network] of this.#clients) { + try { + results[network] = await this.getContract(contractAddress, network); + } catch (error) { + console.warn(`Failed to get contract for network ${network}:`, error); + // You might want to handle this differently based on your requirements + } + } + + return results; + } + + /** + * Get TRC20 token metadata for all configured networks + * + * @param contractAddress - The TRC20 contract address + * @returns Promise> - Token metadata for all networks + */ + async getTRC20TokenMetadataForAllNetworks( + contractAddress: string, + ): Promise> { + const results: Record = {} as Record< + Network, + TRC20TokenMetadata + >; + + for (const [network] of this.#clients) { + try { + results[network] = await this.getTRC20TokenMetadata( + contractAddress, + network, + ); + } catch (error) { + console.warn( + `Failed to get TRC20 token metadata for network ${network}:`, + error, + ); + // You might want to handle this differently based on your requirements + } + } + + return results; + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/index.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/index.ts new file mode 100644 index 00000000..6356e0ac --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/index.ts @@ -0,0 +1,2 @@ +export { TronHttpClient } from './TronHttpClient'; +export type * from './types'; diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts new file mode 100644 index 00000000..0d5fbd09 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export type TronContract = { + origin_address: string; + contract_address: string; + abi: { + entrys: { + type: string; + name: string; + inputs?: { + name: string; + type: string; + }[]; + outputs?: { + name: string; + type: string; + }[]; + }[]; + }; + bytecode: string; + consume_user_resource_percent: number; + name: string; + origin_energy_limit: number; + code_hash: string; +}; + +export type TriggerConstantContractRequest = { + owner_address: string; + contract_address: string; + function_selector: string; + parameter: string; + visible?: boolean; +}; + +export type TriggerConstantContractResponse = { + result: { + result: boolean; + }; + energy_used: number; + constant_result: string[]; + transaction: { + ret: { + contractRet: string; + }[]; + visible: boolean; + txID: string; + raw_data: { + contract: { + parameter: { + value: { + data: string; + owner_address: string; + contract_address: string; + }; + type_url: string; + }; + type: string; + }[]; + ref_block_bytes: string; + ref_block_hash: string; + expiration: number; + timestamp: number; + }; + raw_data_hex: string; + }; +}; + +export type TRC20TokenMetadata = { + name: string; + symbol: string; + decimals: number; +}; + +export type TRC10TokenInfo = { + id: string; + owner_address: string; + name: string; + abbr: string; + total_supply: number; + trx_num: number; + // eslint-disable-next-line id-denylist + num: number; + precision: number; + start_time: number; + end_time: number; + description: string; + url: string; +}; + +export type TRC10TokenMetadata = { + name: string; + symbol: string; + decimals: number; +}; diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts new file mode 100644 index 00000000..9f4a15e1 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts @@ -0,0 +1,84 @@ +import type { + TronAccount, + TrongridApiResponse, +} from './types'; +import type { Network } from '../../constants'; +import type { ConfigProvider } from '../../services/config'; + +export class TrongridApiClient { + readonly #apiKey?: string; + + readonly #clients: Map< + Network, + { + baseUrl: string; + headers: Record; + } + > = new Map(); + + constructor({ configProvider }: { configProvider: ConfigProvider }) { + const { apiKey, baseUrls } = configProvider.get().trongridApi; + this.#apiKey = apiKey; + + // Initialize clients for all networks + Object.entries(baseUrls).forEach(([network, baseUrl]) => { + const headers: Record = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + }; + + if (this.#apiKey) { + headers['TRON-PRO-API-KEY'] = this.#apiKey; + } + + this.#clients.set(network as Network, { baseUrl, headers }); + }); + } + + /** + * Get account information by address for a specific network + * + * @param scope + * @param address - The TRON address to query + * @param network - The network to query + * @returns Promise - Account data in camelCase + */ + async getAccountInfoByAddress( + scope: Network, + address: string, + ): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const url = `${baseUrl}/v1/accounts/${address}`; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData: TrongridApiResponse = + await response.json(); + + if (!rawData.success) { + throw new Error('API request failed'); + } + + if (!rawData.data || rawData.data.length === 0) { + throw new Error('Account not found or no data returned'); + } + + const account = rawData.data[0]; + + if (!account) { + throw new Error('No data'); + } + + return account; + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts new file mode 100644 index 00000000..6088fce1 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts @@ -0,0 +1,118 @@ +import { Account } from "tronweb/lib/esm/types"; + +/* eslint-disable @typescript-eslint/naming-convention */ +export type TrongridApiResponse = { + data: T[]; + success: boolean; + meta: { + at: number; + page_size: number; + }; +}; + +export type TronAccount = { + owner_permission: RawTronPermission; + account_resource: RawTronAccountResource; + active_permission: RawTronPermission[]; + address: string; + create_time: number; + latest_opration_time: number; + frozenV2: RawTronFrozenV2[]; + unfrozenV2: RawTronUnfrozenV2[]; + balance: number; + assetV2?: Record[]; + trc20?: Record[]; + latest_consume_free_time: number; + votes: RawTronVote[]; + latest_withdraw_time: number; + net_window_size: number; + net_window_optimized: boolean; +}; + +export type RawTronPermission = { + keys: RawTronKey[]; + threshold: number; + permission_name: string; + operations?: string; + id?: number; + type?: string; +}; + +export type RawTronKey = { + address: string; + weight: number; +}; + +export type RawTronAccountResource = { + energy_window_optimized: boolean; + energy_window_size: number; +}; + +export type RawTronFrozenV2 = { + amount?: number; + type?: string; +}; + +export type RawTronUnfrozenV2 = { + unfreeze_amount: number; + unfreeze_expire_time: number; +}; + +export type RawTronVote = { + vote_address: string; + vote_count: number; +}; + +// Mapped types (camelCase) +// export type TronAccount = { +// ownerPermission: TronPermission; +// accountResource: TronAccountResource; +// activePermission: TronPermission[]; +// address: string; +// createTime: number; +// latestOperationTime: number; +// frozenV2: TronFrozenV2[]; +// unfrozenV2: TronUnfrozenV2[]; +// balance: number; +// assetV2: Record[]; +// trc20: Record[]; +// latestConsumeFreeTime: number; +// votes: TronVote[]; +// latestWithdrawTime: number; +// netWindowSize: number; +// netWindowOptimized: boolean; +// }; + +// export type TronPermission = { +// keys: TronKey[]; +// threshold: number; +// permissionName: string; +// operations?: string; +// id?: number; +// type?: string; +// }; + +// export type TronKey = { +// address: string; +// weight: number; +// }; + +// export type TronAccountResource = { +// energyWindowOptimized: boolean; +// energyWindowSize: number; +// }; + +// export type TronFrozenV2 = { +// amount?: number; +// type?: string; +// }; + +// export type TronUnfrozenV2 = { +// unfreezeAmount: number; +// unfreezeExpireTime: number; +// }; + +// export type TronVote = { +// voteAddress: string; +// voteCount: number; +// }; diff --git a/merged-packages/tron-wallet-snap/src/constants/index.ts b/merged-packages/tron-wallet-snap/src/constants/index.ts index c2394acf..9de6501c 100644 --- a/merged-packages/tron-wallet-snap/src/constants/index.ts +++ b/merged-packages/tron-wallet-snap/src/constants/index.ts @@ -1,5 +1,7 @@ import { TrxScope } from '@metamask/keyring-api'; +export const NULL_ADDRESS = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb'; + export enum Network { Mainnet = TrxScope.Mainnet, Nile = TrxScope.Nile, diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 17cbba04..9737734b 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -1,6 +1,12 @@ +import { InMemoryCache } from './caching/InMemoryCache'; +import { PriceApiClient } from './clients/price-api/PriceApiClient'; +import { SnapClient } from './clients/snap/SnapClient'; +import { TronHttpClient } from './clients/tron-http/TronHttpClient'; +import { TrongridApiClient } from './clients/trongrid/TrongridApiClient'; import { AssetsHandler } from './handlers/assets'; import { CronHandler } from './handlers/cronjob'; import { KeyringHandler } from './handlers/keyring'; +import { LifecycleHandler } from './handlers/lifecycle'; import { RpcHandler } from './handlers/rpc'; import { UserInputHandler } from './handlers/userInput'; import { AccountsRepository } from './services/accounts/AccountsRepository'; @@ -8,15 +14,19 @@ import { AccountsService } from './services/accounts/AccountsService'; import { AssetsRepository } from './services/assets/AssetsRepository'; import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; -import { Connection } from './services/connection/Connection'; import type { UnencryptedStateValue } from './services/state/State'; import { State } from './services/state/State'; -import { TransactionsService } from './services/transactions/TransactionsService'; import { WalletService } from './services/wallet/WalletService'; -import logger from './utils/logger'; +import logger, { noOpLogger } from './utils/logger'; /** * Services + * + * Dependency injection order: + * 1. Core services (ConfigProvider, State, Connection) + * 2. Repositories (AssetsRepository, TransactionsRepository, AccountsRepository) + * 3. Business services (AssetsService, TransactionsService, AccountsService, WalletService) + * 4. Handlers (AssetsHandler, CronHandler, KeyringHandler, RpcHandler, UserInputHandler) */ export const configProvider = new ConfigProvider(); @@ -30,18 +40,31 @@ const state = new State({ }, }); +const snapClient = new SnapClient(); + +// Repositories - depend on State const assetsRepository = new AssetsRepository(state); -const connection = new Connection(configProvider); +const trongridApiClient = new TrongridApiClient({ + configProvider, +}); +const tronHttpClient = new TronHttpClient({ + configProvider, +}); + +// Cache for PriceApiClient +const priceCache = new InMemoryCache(noOpLogger); +const priceApiClient = new PriceApiClient(configProvider, priceCache); +// Business Services - depend on Repositories, State, Connection, and other Services const assetsService = new AssetsService({ logger, state, assetsRepository, - connection, + trongridApiClient, + tronHttpClient, + priceApiClient, }); -const transactionService = new TransactionsService(); - const walletService = new WalletService({ logger, state, @@ -49,19 +72,30 @@ const walletService = new WalletService({ const accountsRepository = new AccountsRepository(state); -const accountsService = new AccountsService( +const accountsService = new AccountsService({ accountsRepository, configProvider, logger, - connection, assetsService, -); + snapClient, +}); /** * Handlers */ -const assetsHandler = new AssetsHandler(); -const cronHandler = new CronHandler(); +const assetsHandler = new AssetsHandler({ + logger, + assetsService, +}); +const cronHandler = new CronHandler({ + accountsService, + snapClient, +}); +const lifecycleHandler = new LifecycleHandler({ + logger, + accountsService, + snapClient, +}); const keyringHandler = new KeyringHandler({ logger, accountsService, @@ -76,14 +110,15 @@ export type SnapExecutionContext = { */ state: State; assetsService: AssetsService; - transactionService: TransactionsService; walletService: WalletService; accountsService: AccountsService; + tronHttpClient: TronHttpClient; /** * Handlers */ assetsHandler: AssetsHandler; cronHandler: CronHandler; + lifecycleHandler: LifecycleHandler; keyringHandler: KeyringHandler; rpcHandler: RpcHandler; userInputHandler: UserInputHandler; @@ -95,14 +130,15 @@ const snapContext: SnapExecutionContext = { */ state, assetsService, - transactionService, walletService, accountsService, + tronHttpClient, /** * Handlers */ assetsHandler, cronHandler, + lifecycleHandler, keyringHandler, rpcHandler, userInputHandler, @@ -115,6 +151,7 @@ export { assetsHandler, cronHandler, keyringHandler, + lifecycleHandler, rpcHandler, userInputHandler, }; diff --git a/merged-packages/tron-wallet-snap/src/entities/assets.ts b/merged-packages/tron-wallet-snap/src/entities/assets.ts index ae5357cf..4f678c4e 100644 --- a/merged-packages/tron-wallet-snap/src/entities/assets.ts +++ b/merged-packages/tron-wallet-snap/src/entities/assets.ts @@ -1,8 +1,8 @@ import type { Network } from '../constants'; import type { NativeCaipAssetType, - TokenCaipAssetType, NftCaipAssetType, + TokenCaipAssetType, } from '../services/assets/types'; export type NativeAsset = { diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts index aa35e1a8..13d7633d 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/assets.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -9,9 +9,26 @@ import type { OnAssetsMarketDataResponse, } from '@metamask/snaps-sdk'; -import context from '../context'; +import type { AssetsService } from '../services/assets/AssetsService'; +import type { ILogger } from '../utils/logger'; +import { createPrefixedLogger } from '../utils/logger'; export class AssetsHandler { + readonly #logger: ILogger; + + readonly #assetsService: AssetsService; + + constructor({ + logger, + assetsService, + }: { + logger: ILogger; + assetsService: AssetsService; + }) { + this.#logger = createPrefixedLogger(logger, '[🪙 AssetsHandler]'); + this.#assetsService = assetsService; + } + async onAssetHistoricalPrice( _params: OnAssetHistoricalPriceArguments, ): Promise { @@ -24,34 +41,33 @@ export class AssetsHandler { } async onAssetsConversion( - _conversions: OnAssetsConversionArguments, + params: OnAssetsConversionArguments, ): Promise { + this.#logger.log('[💱 onAssetsConversion]'); + + const { conversions } = params; + + const conversionRates = + await this.#assetsService.getMultipleTokenConversions(conversions); + return { - conversionRates: { - 'tron:728126428/slip44:195': { - 'swift:0/iso4217:USD': { - rate: '0.27', - conversionTime: Date.now(), - expirationTime: Date.now() + 1000 * 60 * 60 * 24, - }, - }, - }, + conversionRates, }; } async onAssetsLookup( params: OnAssetsLookupArguments, ): Promise { - const assets = await context.assetsService.getAssetsMetadata(params.assets); - - return { - assets, - }; + const assets = await this.#assetsService.getAssetsMetadata(params.assets); + return { assets }; } async onAssetsMarketData( - _assets: OnAssetsMarketDataArguments, + params: OnAssetsMarketDataArguments, ): Promise { - return { marketData: {} }; + const marketData = await this.#assetsService.getMultipleTokensMarketData( + params.assets, + ); + return { marketData }; } } diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts index e73ae2cd..c0d8d031 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts @@ -1,14 +1,62 @@ import type { JsonRpcRequest } from '@metamask/utils'; +import type { SnapClient } from '../clients/snap/SnapClient'; +import type { AccountsService } from '../services/accounts/AccountsService'; + +export enum CronMethod { + SynchronizeAccounts = 'scheduleRefreshAccounts', +} + export class CronHandler { - async handle({ - request: _request, + readonly #accountsService: AccountsService; + + readonly #snapClient: SnapClient; + + constructor({ + accountsService, + snapClient, }: { - request: JsonRpcRequest; - }): Promise { - /** - * Map cronjob to the appropriate handler - */ - // TODO: No cronjobs yet + accountsService: AccountsService; + snapClient: SnapClient; + }) { + this.#accountsService = accountsService; + this.#snapClient = snapClient; + } + + async handle(request: JsonRpcRequest): Promise { + const { method } = request; + const { active } = await this.#snapClient.getClientStatus(); + + if (!active) { + return; + } + + switch (method as CronMethod) { + case CronMethod.SynchronizeAccounts: + await this.synchronizeAccounts(); + break; + default: + throw new Error(`Unknown cronjob method: ${method}`); + } + } + + /** + * Synchronizes all accounts (assets and transactions). + * This can be called by cron jobs to keep data fresh. + */ + async synchronizeAccounts(): Promise { + const { active } = await this.#snapClient.getClientStatus(); + + if (!active) { + return; + } + + const accounts = await this.#accountsService.getAll(); + await this.#accountsService.synchronize(accounts); + + await this.#snapClient.scheduleBackgroundEvent({ + method: CronMethod.SynchronizeAccounts, + duration: '30s', + }); } } diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index d2de1349..da092633 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -10,7 +10,6 @@ import { type KeyringAccountData, type KeyringRequest, type KeyringResponse, - type Paginated, type Pagination, type ResolvedAccountAddress, type Transaction, @@ -138,7 +137,6 @@ export class KeyringHandler implements Keyring { this.#logger.info('Listing account assets', { accountId }); const assets = await this.#assetsService.getByKeyringAccountId(accountId); - const result = assets.map((asset) => asset.assetType); this.#logger.info('Account assets', { accountId, result }); @@ -151,11 +149,13 @@ export class KeyringHandler implements Keyring { } } - async listAccountTransactions?( - id: string, + async listAccountTransactions( + accountId: string, pagination: Pagination, - ): Promise> { - // TODO: Implement me + ): Promise<{ + data: Transaction[]; + next: string | null; + }> { return { data: [], next: null, @@ -167,7 +167,6 @@ export class KeyringHandler implements Keyring { entropySource: EntropySourceId, groupIndex: number, ): Promise { - // TODO: Implement me return []; } @@ -182,6 +181,7 @@ export class KeyringHandler implements Keyring { const assetsList = await this.#assetsService.getByKeyringAccountId(accountId); + const assetsOnlyRequestedAssetTypes = assetsList.filter((asset) => assets.includes(asset.assetType), ); @@ -190,8 +190,8 @@ export class KeyringHandler implements Keyring { Record >((acc, asset) => { acc[asset.assetType] = { - unit: asset.symbol, - amount: asset.uiAmount, + unit: asset.symbol ?? '', + amount: asset.uiAmount ?? '', }; return acc; }, {}); diff --git a/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts b/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts new file mode 100644 index 00000000..d7b5b27b --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts @@ -0,0 +1,42 @@ +import { CronMethod } from './cronjob'; +import type { SnapClient } from '../clients/snap/SnapClient'; +import type { AccountsService } from '../services/accounts/AccountsService'; +import type { ILogger } from '../utils/logger'; +import { createPrefixedLogger } from '../utils/logger'; + +export class LifecycleHandler { + readonly #logger: ILogger; + + readonly #accountsService: AccountsService; + + readonly #snapClient: SnapClient; + + constructor({ + logger, + accountsService, + snapClient, + }: { + logger: ILogger; + accountsService: AccountsService; + snapClient: SnapClient; + }) { + this.#logger = createPrefixedLogger(logger, '[👵 LifecycleHandler]'); + this.#accountsService = accountsService; + this.#snapClient = snapClient; + } + + /** + * Called when the extension is made active. + */ + async onActive(): Promise { + this.#logger.log('[🔋 onActive]'); + + const accounts = await this.#accountsService.getAll(); + await this.#accountsService.synchronize(accounts); + + await this.#snapClient.scheduleBackgroundEvent({ + method: CronMethod.SynchronizeAccounts, + duration: '10s', + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/index.ts b/merged-packages/tron-wallet-snap/src/index.ts index b2a4b009..7b0f589f 100644 --- a/merged-packages/tron-wallet-snap/src/index.ts +++ b/merged-packages/tron-wallet-snap/src/index.ts @@ -1,4 +1,5 @@ import type { + OnActiveHandler, OnAssetHistoricalPriceHandler, OnAssetsConversionHandler, OnAssetsLookupHandler, @@ -13,6 +14,7 @@ import { assetsHandler, cronHandler, keyringHandler, + lifecycleHandler, rpcHandler, userInputHandler, } from './context'; @@ -34,8 +36,8 @@ export const onAssetsLookup: OnAssetsLookupHandler = async (args) => export const onAssetsMarketData: OnAssetsMarketDataHandler = async (args) => assetsHandler.onAssetsMarketData(args); -export const onCronjob: OnCronjobHandler = async (args) => - cronHandler.handle(args); +export const onCronjob: OnCronjobHandler = async ({ request }) => + cronHandler.handle(request); export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, @@ -47,3 +49,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => export const onUserInput: OnUserInputHandler = async (params) => userInputHandler.handle(params); + +export const onActive: OnActiveHandler = async () => { + lifecycleHandler.onActive(); +}; diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 0365059b..afad1bca 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -2,22 +2,19 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { assert, integer } from '@metamask/superstruct'; -import type { Account } from 'tronweb/lib/esm/types'; -import type { AccountsRepository } from './AccountsRepository'; -import type { CreateAccountOptions } from './types'; -import type { Network } from '../../constants'; +import type { SnapClient } from '../../clients/snap/SnapClient'; import { asStrictKeyringAccount, type TronKeyringAccount, } from '../../entities'; import { deriveTronKeypair } from '../../utils/deriveTronKeypair'; import { getLowestUnusedIndex } from '../../utils/getLowestUnusedIndex'; -import { listEntropySources } from '../../utils/interface'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; import type { AssetsService } from '../assets/AssetsService'; import type { ConfigProvider } from '../config'; -import type { Connection } from '../connection/Connection'; +import type { AccountsRepository } from './AccountsRepository'; +import type { CreateAccountOptions } from './types'; export class AccountsService { readonly #accountsRepository: AccountsRepository; @@ -26,22 +23,28 @@ export class AccountsService { readonly #logger: ILogger; - readonly #connection: Connection; - readonly #assetsService: AssetsService; - constructor( - accountsRepository: AccountsRepository, - configProvider: ConfigProvider, - logger: ILogger, - connection: Connection, - assetsService: AssetsService, - ) { - this.#accountsRepository = accountsRepository; - this.#configProvider = configProvider; + readonly #snapClient: SnapClient; + + constructor({ + accountsRepository, + configProvider, + logger, + assetsService, + snapClient, + }: { + accountsRepository: AccountsRepository; + configProvider: ConfigProvider; + logger: ILogger; + assetsService: AssetsService; + snapClient: SnapClient; + }) { this.#logger = createPrefixedLogger(logger, '[🔑 AccountsService]'); - this.#connection = connection; + this.#configProvider = configProvider; + this.#accountsRepository = accountsRepository; this.#assetsService = assetsService; + this.#snapClient = snapClient; } async create( @@ -158,34 +161,52 @@ export class AccountsService { return this.#accountsRepository.delete(id); } - async fetch(account: KeyringAccount, scope: Network): Promise { - this.#logger.info('Fetching account', { address: account.address, scope }); + async synchronize(accounts: KeyringAccount[]): Promise { + const scopes = this.#configProvider.get().activeNetworks; + const combinations = accounts.flatMap((account) => + scopes.map((scope) => ({ account, scope })), + ); - const tronAccount = await this.#connection - .getConnection(scope) - .trx.getAccount(account.address); + // Synchronize assets + const assetResponses = await Promise.allSettled( + combinations.map(async ({ account, scope }) => { + return this.#assetsService.fetchAssetsAndBalancesByAccount( + scope, + account, + ); + }), + ); - return tronAccount; + const assets = assetResponses.flatMap((response) => + response.status === 'fulfilled' ? response.value : [], + ); + + await this.#assetsService.saveMany(assets); } - async synchronize(accounts: KeyringAccount[]): Promise { + /** + * Synchronizes only assets for the given accounts. + * This method can be called independently to sync assets without syncing transactions. + * + * @param accounts - The accounts to synchronize assets for. + */ + async synchronizeAssets(accounts: KeyringAccount[]): Promise { const scopes = this.#configProvider.get().activeNetworks; const combinations = accounts.flatMap((account) => scopes.map((scope) => ({ account, scope })), ); - const responses = await Promise.allSettled( + // Synchronize assets only + const assetResponses = await Promise.allSettled( combinations.map(async ({ account, scope }) => { - const tronAccount = await this.fetch(account, scope); - return this.#assetsService.fetchAssetsByAccount( - account, + return this.#assetsService.fetchAssetsAndBalancesByAccount( scope, - tronAccount, + account, ); }), ); - const assets = responses.flatMap((response) => + const assets = assetResponses.flatMap((response) => response.status === 'fulfilled' ? response.value : [], ); @@ -222,7 +243,7 @@ export class AccountsService { } async #getDefaultEntropySource(): Promise { - const entropySources = await listEntropySources(); + const entropySources = await this.#snapClient.listEntropySources(); const defaultEntropySource = entropySources.find(({ primary }) => primary); if (!defaultEntropySource) { diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsSynchronizer.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsSynchronizer.ts deleted file mode 100644 index 4d84e77f..00000000 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsSynchronizer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { AccountsService } from './AccountsService'; -import type { TronKeyringAccount } from '../../entities'; -import { createPrefixedLogger, type ILogger } from '../../utils/logger'; - -export class AccountsSynchronizer { - readonly #accountsService: AccountsService; - - readonly #logger: ILogger; - - constructor(accountsService: AccountsService, logger: ILogger) { - this.#accountsService = accountsService; - this.#logger = createPrefixedLogger(logger, '[🔄 AccountsSynchronizer]'); - } - - async synchronize(accounts?: TronKeyringAccount[]): Promise { - const accountsToSync = accounts ?? (await this.#accountsService.getAll()); - - this.#logger.info('Synchronizing accounts', accountsToSync); - - await this.#accountsService.synchronize(accountsToSync); - } -} diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 89a63a96..51d7ff37 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,19 +1,20 @@ import { KeyringEvent, - type AccountBalancesUpdatedEvent, type AccountAssetListUpdatedEvent, + type AccountBalancesUpdatedEvent, type KeyringAccount, } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { + AssetConversion, AssetMetadata, + FungibleAssetMarketData, FungibleAssetMetadata, - NonFungibleAssetMetadata, } from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; -import { cloneDeep } from 'lodash'; -import type { Account } from 'tronweb/lib/esm/types'; +import { BigNumber } from 'bignumber.js'; +import { pick } from 'lodash'; import type { AssetsRepository } from './AssetsRepository'; import type { @@ -21,10 +22,15 @@ import type { NftCaipAssetType, TokenCaipAssetType, } from './types'; -import { Network } from '../../constants'; +import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; +import type { FiatTicker, SpotPrice } from '../../clients/price-api/types'; +import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { TronAccount } from '../../clients/trongrid/types'; +import type { Network } from '../../constants'; +import { configProvider } from '../../context'; import type { AssetEntity } from '../../entities/assets'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; -import type { Connection } from '../connection/Connection'; import type { State, UnencryptedStateValue } from '../state/State'; export class AssetsService { @@ -32,25 +38,197 @@ export class AssetsService { readonly #assetsRepository: AssetsRepository; - readonly #connection: Connection; - readonly #state: State; + readonly #trongridApiClient: TrongridApiClient; + + readonly #tronHttpClient: TronHttpClient; + + readonly #priceApiClient: PriceApiClient; + + readonly cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + constructor({ logger, assetsRepository, - connection, state, + trongridApiClient, + tronHttpClient, + priceApiClient, }: { logger: ILogger; assetsRepository: AssetsRepository; - connection: Connection; state: State; + trongridApiClient: TrongridApiClient; + tronHttpClient: TronHttpClient; + priceApiClient: PriceApiClient; }) { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); this.#assetsRepository = assetsRepository; - this.#connection = connection; this.#state = state; + this.#trongridApiClient = trongridApiClient; + this.#tronHttpClient = tronHttpClient; + this.#priceApiClient = priceApiClient; + + const { cacheTtlsMilliseconds } = configProvider.get().priceApi; + this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; + } + + async fetchAssetsAndBalancesByAccount( + scope: Network, + account: KeyringAccount, + ): Promise { + this.#logger.info('Fetching assets and balances by account', { + account, + scope, + }); + + const tronAccountInfo = + await this.#trongridApiClient.getAccountInfoByAddress( + scope, + account.address, + ); + + const nativeAsset = this.#extractNativeAsset({ + account, + scope, + tronAccountInfo, + }); + const trc10Assets = this.#extractTrc10Assets({ + account, + scope, + tronAccountInfo, + }); + const trc20Assets = this.#extractTrc20Assets({ + account, + scope, + tronAccountInfo, + }); + + const assetTypes = [ + nativeAsset.assetType, + ...trc10Assets.map((tokenAsset) => tokenAsset.assetType), + ...trc20Assets.map((tokenAsset) => tokenAsset.assetType), + ]; + const assetsMetadata = await this.getAssetsMetadata(assetTypes); + + const assets = [nativeAsset, ...trc10Assets, ...trc20Assets].map( + (asset) => { + const metadata = assetsMetadata[asset.assetType]; + const mergedAsset = { + ...asset, + ...metadata, + } as any; + + const unit = metadata?.fungible ? metadata.units?.[0] : undefined; + + if (unit) { + const decimals = new BigNumber(mergedAsset.rawAmount).dividedBy( + new BigNumber(10).pow(unit.decimals), + ); + mergedAsset.uiAmount = decimals.toString(); + } else { + mergedAsset.uiAmount = '0'; + } + + return mergedAsset; + }, + ); + + return assets; + } + + static isFiat(caipAssetId: CaipAssetType): boolean { + return caipAssetId.includes('swift:0/iso4217:'); + } + + #extractNativeAsset({ + account, + scope, + tronAccountInfo, + }: { + account: KeyringAccount; + scope: Network; + tronAccountInfo: TronAccount; + }): AssetEntity { + const asset: AssetEntity = { + assetType: `${scope}/slip44:195` as NativeCaipAssetType, + keyringAccountId: account.id, + network: scope, + address: account.address, + symbol: 'TRX', + decimals: 6, + rawAmount: tronAccountInfo.balance.toString(), + uiAmount: new BigNumber(tronAccountInfo.balance) + .dividedBy(10 ** 6) + .toString(), + }; + + return asset; + } + + #extractTrc10Assets({ + account, + scope, + tronAccountInfo, + }: { + account: KeyringAccount; + scope: Network; + tronAccountInfo: TronAccount; + }): AssetEntity[] { + const { assetV2 } = tronAccountInfo; + + const trc10Assets = assetV2?.flatMap((tokenObject) => { + return Object.entries(tokenObject).map(([address, balance]) => { + return { + assetType: `${scope}/trc10:${address}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + mint: address, + pubkey: address, + symbol: '', + decimals: 0, + rawAmount: balance, + uiAmount: '0', + }; + }); + }) ?? []; + + return trc10Assets; + } + + #extractTrc20Assets({ + account, + scope, + tronAccountInfo, + }: { + account: KeyringAccount; + scope: Network; + tronAccountInfo: TronAccount; + }): AssetEntity[] { + const { trc20 } = tronAccountInfo; + + const trc20Assets = trc20?.flatMap((tokenObject) => { + return Object.entries(tokenObject).map(([address, balance]) => { + return { + assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + mint: address, + pubkey: address, + symbol: '', + decimals: 0, + rawAmount: balance, + uiAmount: '0', + }; + }); + }) ?? []; + + return trc20Assets; } async getAssetsMetadata( @@ -58,30 +236,20 @@ export class AssetsService { ): Promise> { this.#logger.info('Fetching metadata for assets', assetTypes); - const { - nativeAssetTypes, - tokenTrc10AssetTypes, - tokenTrc20AssetTypes, - nftAssetTypes, - } = this.#splitAssetsByType(assetTypes); - - const [ - nativeTokensMetadata, - trc10TokensMetadata, - trc20TokensMetadata, - nftMetadata, - ] = await Promise.all([ - this.#getNativeTokensMetadata(nativeAssetTypes), - this.#getTRC10TokensMetadata(tokenTrc10AssetTypes, Network.Mainnet), - this.#getTRC20TokensMetadata(tokenTrc20AssetTypes, Network.Mainnet), - this.#getNftsMetadata(nftAssetTypes), - ]); + const { nativeAssetTypes, tokenTrc10AssetTypes, tokenTrc20AssetTypes } = + this.#splitAssetsByType(assetTypes); + + const [nativeTokensMetadata, trc10TokensMetadata, trc20TokensMetadata] = + await Promise.all([ + this.#getNativeTokensMetadata(nativeAssetTypes), + this.#getTRC10TokensMetadata(tokenTrc10AssetTypes), + this.#getTRC20TokensMetadata(tokenTrc20AssetTypes), + ]); const result = { ...nativeTokensMetadata, ...trc10TokensMetadata, ...trc20TokensMetadata, - ...nftMetadata, }; this.#logger.info('Resolved assets metadata', { assetTypes, result }); @@ -96,7 +264,7 @@ export class AssetsService { nftAssetTypes: NftCaipAssetType[]; } { const nativeAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('slip44:195'), + assetType.endsWith('/slip44:195'), ) as NativeCaipAssetType[]; const tokenTrc10AssetTypes = assetTypes.filter((assetType) => assetType.includes('/trc10:'), @@ -105,7 +273,7 @@ export class AssetsService { assetType.includes('/trc20:'), ) as TokenCaipAssetType[]; const nftAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc721'), + assetType.includes('/trc721:'), ) as NftCaipAssetType[]; return { @@ -127,17 +295,17 @@ export class AssetsService { for (const assetType of assetTypes) { // const { chainId } = parseCaipAssetType(assetType); nativeTokensMetadata[assetType] = { - name: 'TRX', + name: 'Tron', symbol: 'TRX', fungible: true as const, // iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, iconUrl: - 'https://altcoinsbox.com/wp-content/uploads/2023/01/tron-coin-logo-300x300.webp', + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', units: [ { - name: 'TRX', + name: 'Tron', symbol: 'TRX', - decimals: 9, + decimals: 6, }, ], }; @@ -148,24 +316,27 @@ export class AssetsService { async #getTRC10TokensMetadata( assetTypes: TokenCaipAssetType[], - scope: Network, ): Promise> { - const connection = this.#connection.getConnection(scope); const metadata = await Promise.all( assetTypes.map(async (assetType) => { - const { assetReference } = parseCaipAssetType(assetType); - const tokenMetadata = await connection.trx.getTokenByID(assetReference); + const { chainId, assetReference } = parseCaipAssetType(assetType); + + const tokenMetadata = await this.#tronHttpClient.getTRC10TokenMetadata( + assetReference, + chainId as Network, + ); return { [assetType]: { name: tokenMetadata.name, - symbol: tokenMetadata.abbr, + symbol: tokenMetadata.symbol, fungible: true as const, iconUrl: `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/assets/${assetReference}/logo.png`, units: [ { - decimals: tokenMetadata.precision, - symbol: tokenMetadata.abbr, + name: tokenMetadata.name, + symbol: tokenMetadata.symbol, + decimals: tokenMetadata.decimals, }, ], }, @@ -180,19 +351,12 @@ export class AssetsService { async #getTRC20TokensMetadata( assetTypes: TokenCaipAssetType[], - scope: Network, ): Promise> { const metadata = await Promise.all( assetTypes.map(async (assetType) => { const { assetReference } = parseCaipAssetType(assetType); - - const contract = await this.#connection - .getConnection(scope) - .trx.getContract(assetReference); - - const name = await contract.name(); - const symbol = await contract.symbol(); - const decimals = await contract.decimals(); + const { name, symbol, decimals } = + await this.#getTrc20TokenMetadata(assetType); return { [assetType]: { @@ -200,7 +364,7 @@ export class AssetsService { symbol, fungible: true as const, iconUrl: `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/assets/${assetReference}/logo.png`, - units: [{ decimals, symbol }], + units: [{ name, symbol, decimals }], }, }; }), @@ -211,88 +375,23 @@ export class AssetsService { }, {}); } - async #getNftsMetadata( - _assetTypes: NftCaipAssetType[], - ): Promise> { - return {}; // TODO: Implement me - } - - async fetchAssetsByAccount( - account: KeyringAccount, - scope: Network, - tronAccount: Account, - ): Promise { - const [nativeAsset, trc10Assets, trc20Assets] = await Promise.allSettled([ - this.#getNativeAsset(account, scope, tronAccount), - this.#fetchTRC10Assets(account, scope, tronAccount), - this.#fetchTRC20Assets(account, scope), - ]); - - return [ - ...(nativeAsset.status === 'fulfilled' ? [nativeAsset.value] : []), - ...(trc10Assets.status === 'fulfilled' ? trc10Assets.value : []), - ...(trc20Assets.status === 'fulfilled' ? trc20Assets.value : []), - ]; - } - - #getNativeAsset( - account: KeyringAccount, - scope: Network, - tronAccount: Account, - ): AssetEntity { - const asset: AssetEntity = { - assetType: `${scope}/slip44:195` as NativeCaipAssetType, - keyringAccountId: account.id, - network: scope, - address: account.address, - symbol: 'TRX', - decimals: 9, - rawAmount: tronAccount?.balance?.toString(), - uiAmount: tronAccount?.balance?.toString(), - }; - - return asset; - } - - async #fetchTRC10Assets( - account: KeyringAccount, - scope: Network, - tronAccount: Account, - ): Promise { - const connection = this.#connection.getConnection(scope); - - const responses = await Promise.allSettled( - tronAccount.assetV2.map(async (asset): Promise => { - if (!asset.key || !asset.value) { - throw new Error('Asset ID is required'); - } - - const metadata = await connection.trx.getTokenByID(asset.key); - - return { - assetType: `${scope}/trc10:${asset.key}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: metadata.abbr, - decimals: metadata.precision, - mint: metadata.owner_address, - rawAmount: asset.value.toString(), - uiAmount: asset.value.toString(), - pubkey: asset.key.toString(), - }; - }), - ); + async #getTrc20TokenMetadata(assetType: TokenCaipAssetType): Promise<{ + name: string; + symbol: string; + decimals: number; + }> { + const { chainId, assetReference } = parseCaipAssetType(assetType); - return responses.flatMap((response) => - response.status === 'fulfilled' ? response.value : [], + const tokenMetadata = await this.#tronHttpClient.getTRC20TokenMetadata( + assetReference, + chainId as Network, ); - } - async #fetchTRC20Assets( - _account: KeyringAccount, - _scope: Network, - ): Promise { - return []; // TODO: Implement me + return { + name: tokenMetadata.name, + symbol: tokenMetadata.symbol, + decimals: tokenMetadata.decimals, + }; } /** @@ -321,33 +420,6 @@ export class AssetsService { const savedAssets = await this.getAll(); - // Update the state atomically - await this.#state.update((stateValue) => { - const newState = cloneDeep(stateValue); - for (const asset of assets) { - const { keyringAccountId } = asset; - const accountAssets = cloneDeep( - newState.assets[keyringAccountId] ?? [], - ); - - // Avoid duplicates. If same asset is already saved, override it. - const existingAssetIndex = accountAssets.findIndex( - (item) => - item.assetType === asset.assetType && - item.keyringAccountId === asset.keyringAccountId, - ); - - if (existingAssetIndex === -1) { - accountAssets.push(asset); - } else { - accountAssets[existingAssetIndex] = asset; - } - - newState.assets[keyringAccountId] = accountAssets; - } - return newState; - }); - this.#logger.info('Saved assets', { savedAssets }); const hasZeroRawAmount = (asset: AssetEntity): boolean => @@ -479,4 +551,302 @@ export class AssetsService { ): Promise { return this.#assetsRepository.getByAccountId(keyringAccountId); } + + /** + * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. + * + * @param caipAssetType - The CAIP-19 asset type. + * @returns The fiat ticker. + */ + #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { + if (!AssetsService.isFiat(caipAssetType)) { + throw new Error('Passed caipAssetType is not a fiat asset'); + } + + const fiatTicker = + parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); + + return fiatTicker as FiatTicker; + } + + /** + * Fetches fiat exchange rates and crypto prices for the given assets. + * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. + * + * @param allAssets - Array of all CAIP asset types (both fiat and crypto). + * @returns Promise resolving to fiat exchange rates and crypto prices. + */ + async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ + fiatExchangeRates: Record; + cryptoPrices: Record; + }> { + const cryptoAssets = allAssets.filter((asset) => !AssetsService.isFiat(asset)); + + const [fiatExchangeRates, cryptoPrices] = await Promise.all([ + this.#priceApiClient.getFiatExchangeRates(), + this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), + ]); + + return { fiatExchangeRates, cryptoPrices }; + } + + /** + * Get the token conversions for a list of asset pairs. + * It caches the results for 1 hour. + * + * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. This is not entirely accurate but it's the + * best we can do with the current API. + * + * @param conversions - The asset pairs to get the conversions for. + * @returns The token conversions. + */ + async getMultipleTokenConversions( + conversions: { from: CaipAssetType; to: CaipAssetType }[], + ): Promise< + Record> + > { + if (conversions.length === 0) { + return {}; + } + + /** + * `from` and `to` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = conversions.flatMap((conversion) => [ + conversion.from, + conversion.to, + ]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + /** + * Now that we have the data, convert the `from`s to `to`s. + * + * We need to handle the following cases: + * 1. `from` and `to` are both fiat + * 2. `from` and `to` are both crypto + * 3. `from` is fiat and `to` is crypto + * 4. `from` is crypto and `to` is fiat + * + * We also need to keep in mind that although `cryptoPrices` are indexed + * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. + * To convert fiat currency symbols to CAIP 19 IDs, we can use the + * `this.#fiatSymbolToCaip19Id` method. + */ + + const result: Record< + CaipAssetType, + Record + > = {}; + + conversions.forEach((conversion) => { + const { from, to } = conversion; + + if (!result[from]) { + result[from] = {}; + } + + let fromUsdRate: BigNumber; + let toUsdRate: BigNumber; + + if (AssetsService.isFiat(from)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(from)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); + } + + if (AssetsService.isFiat(to)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(to)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); + } + + if (fromUsdRate.isZero() || toUsdRate.isZero()) { + result[from][to] = null; + return; + } + + const rate = fromUsdRate.dividedBy(toUsdRate).toString(); + + const now = Date.now(); + + result[from][to] = { + rate, + conversionTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + }); + + return result; + } + + /** + * Computes the market data object in the target currency. + * + * @param spotPrice - The spot price of the asset in source currency. + * @param rate - The rate to convert the market data to from source currency to target currency. + * @returns The market data in the target currency. + */ + #computeMarketData( + spotPrice: SpotPrice, + rate: BigNumber, + ): FungibleAssetMarketData { + const marketDataInUsd = pick(spotPrice, [ + 'marketCap', + 'totalVolume', + 'circulatingSupply', + 'allTimeHigh', + 'allTimeLow', + 'pricePercentChange1h', + 'pricePercentChange1d', + 'pricePercentChange7d', + 'pricePercentChange14d', + 'pricePercentChange30d', + 'pricePercentChange200d', + 'pricePercentChange1y', + ]); + + const toCurrency = (value: number | null | undefined): string => { + return value === null || value === undefined + ? '' + : new BigNumber(value).dividedBy(rate).toString(); + }; + + const includeIfDefined = ( + key: string, + value: number | null | undefined, + ): Record => { + return value === null || value === undefined ? {} : { [key]: value }; + }; + + // Variations in percent don't need to be converted, they are independent of the currency + const pricePercentChange = { + ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), + ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), + ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), + ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), + ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), + ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), + ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), + }; + + const marketDataInToCurrency = { + fungible: true, + marketCap: toCurrency(marketDataInUsd.marketCap), + totalVolume: toCurrency(marketDataInUsd.totalVolume), + circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert + allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), + allTimeLow: toCurrency(marketDataInUsd.allTimeLow), + // Add pricePercentChange field only if it has values + ...(Object.keys(pricePercentChange).length > 0 + ? { pricePercentChange } + : {}), + } as FungibleAssetMarketData; + + return marketDataInToCurrency; + } + + async getMultipleTokensMarketData( + assets: { + asset: CaipAssetType; + unit: CaipAssetType; + }[], + ): Promise< + Record> + > { + if (assets.length === 0) { + return {}; + } + + /** + * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + const result: Record< + CaipAssetType, + Record + > = {}; + + assets.forEach((asset) => { + const { asset: assetType, unit } = asset; + + // Skip if we don't have price data for the asset + if (!cryptoPrices[assetType]) { + return; + } + + let unitUsdRate: BigNumber; + + if (AssetsService.isFiat(unit)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; + + if (!fiatExchangeRate) { + return; + } + + unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); + } + + if (unitUsdRate.isZero()) { + return; + } + + // Initialize the nested structure for the asset if it doesn't exist + if (!result[assetType]) { + result[assetType] = {}; + } + + // Store the market data with the unit as the key + result[assetType][unit] = this.#computeMarketData( + cryptoPrices[assetType], + unitUsdRate, + ); + }); + + return result; + } } diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index 9c1a00a1..bbf27247 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -46,7 +46,15 @@ const EnvStruct = object({ SECURITY_ALERTS_API_BASE_URL: UrlStruct, NFT_API_BASE_URL: UrlStruct, LOCAL_API_BASE_URL: string(), - TRON_API_KEY: string(), + TRONGRID_API_KEY: string(), + TRONGRID_BASE_URL_MAINNET: UrlStruct, + TRONGRID_BASE_URL_NILE: UrlStruct, + TRONGRID_BASE_URL_SHASTA: UrlStruct, + TRONGRID_BASE_URL_LOCALNET: UrlStruct, + TRON_HTTP_BASE_URL_MAINNET: UrlStruct, + TRON_HTTP_BASE_URL_NILE: UrlStruct, + TRON_HTTP_BASE_URL_SHASTA: UrlStruct, + TRON_HTTP_BASE_URL_LOCALNET: UrlStruct, }); export type Env = Infer; @@ -89,8 +97,12 @@ export type Config = { getNftMetadata: number; }; }; - tronApi: { + trongridApi: { apiKey: string; + baseUrls: Record; + }; + tronHttpApi: { + baseUrls: Record; }; }; @@ -131,7 +143,17 @@ export class ConfigProvider { SECURITY_ALERTS_API_BASE_URL: process.env.SECURITY_ALERTS_API_BASE_URL, NFT_API_BASE_URL: process.env.NFT_API_BASE_URL, LOCAL_API_BASE_URL: process.env.LOCAL_API_BASE_URL, - TRON_API_KEY: process.env.TRON_API_KEY, + // // TronGrid API + TRONGRID_BASE_URL_MAINNET: process.env.TRONGRID_BASE_URL_MAINNET, + TRONGRID_BASE_URL_NILE: process.env.TRONGRID_BASE_URL_NILE, + TRONGRID_BASE_URL_SHASTA: process.env.TRONGRID_BASE_URL_SHASTA, + TRONGRID_BASE_URL_LOCALNET: process.env.TRONGRID_BASE_URL_LOCALNET, + TRONGRID_API_KEY: process.env.TRONGRID_API_KEY, + // // Tron HTTP API URLs + TRON_HTTP_BASE_URL_MAINNET: process.env.TRON_HTTP_BASE_URL_MAINNET, + TRON_HTTP_BASE_URL_NILE: process.env.TRON_HTTP_BASE_URL_NILE, + TRON_HTTP_BASE_URL_SHASTA: process.env.TRON_HTTP_BASE_URL_SHASTA, + TRON_HTTP_BASE_URL_LOCALNET: process.env.TRON_HTTP_BASE_URL_LOCALNET, }; // Validate and parse them before returning @@ -205,8 +227,22 @@ export class ConfigProvider { getNftMetadata: Duration.Minute, }, }, - tronApi: { - apiKey: environment.TRON_API_KEY, + trongridApi: { + apiKey: environment.TRONGRID_API_KEY, + baseUrls: { + [Network.Mainnet]: environment.TRONGRID_BASE_URL_MAINNET, + [Network.Nile]: environment.TRONGRID_BASE_URL_NILE, + [Network.Shasta]: environment.TRONGRID_BASE_URL_SHASTA, + [Network.Localnet]: environment.TRONGRID_BASE_URL_LOCALNET, + }, + }, + tronHttpApi: { + baseUrls: { + [Network.Mainnet]: environment.TRON_HTTP_BASE_URL_MAINNET, + [Network.Nile]: environment.TRON_HTTP_BASE_URL_NILE, + [Network.Shasta]: environment.TRON_HTTP_BASE_URL_SHASTA, + [Network.Localnet]: environment.TRON_HTTP_BASE_URL_LOCALNET, + }, }, }; } diff --git a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts index 48cd13f8..5a37777a 100644 --- a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts +++ b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts @@ -1,51 +1,50 @@ import { assert } from '@metamask/superstruct'; -import { TronWeb, providers } from 'tronweb'; import type { Network } from '../../constants'; import { NetworkStruct } from '../../validation/structs'; import type { ConfigProvider } from '../config'; +/** + * Simplified connection service that can be extended if needed + * Most functionality has been moved to specialized HTTP clients + */ export class Connection { - readonly #networkCaip2IdToConnection: Map = new Map(); - readonly #configProvider: ConfigProvider; constructor(configProvider: ConfigProvider) { this.#configProvider = configProvider; } - #createConnection(network: Network): TronWeb { - const config = this.#configProvider.getNetworkBy('caip2Id', network); - const rpcUrl = config.rpcUrls[0] ?? ''; - const fullNode = new providers.HttpProvider(rpcUrl); - const solidityNode = new providers.HttpProvider(rpcUrl); - const eventServer = new providers.HttpProvider(rpcUrl); - - const headers = { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Headers': '*', - 'Access-Control-Allow-Origin': '*', - 'TRON-PRO-API-KEY': this.#configProvider.get().tronApi.apiKey, - }; - - const connection = new TronWeb({ - fullNode, - solidityNode, - eventServer, - }); - - connection.setHeader(headers); - - this.#networkCaip2IdToConnection.set(network, connection); + /** + * Get network configuration for a specific network + * + * @param network - The network to get configuration for + * @returns Network configuration + */ + getNetworkConfig(network: Network) { + assert(network, NetworkStruct); + return this.#configProvider.getNetworkBy('caip2Id', network); + } - return connection; + /** + * Get RPC URLs for a specific network + * + * @param network - The network to get RPC URLs for + * @returns Array of RPC URLs + */ + getRpcUrls(network: Network): string[] { + const config = this.getNetworkConfig(network); + return config.rpcUrls; } - getConnection(network: Network): TronWeb { - assert(network, NetworkStruct); - return ( - this.#networkCaip2IdToConnection.get(network) ?? - this.#createConnection(network) - ); + /** + * Get the primary RPC URL for a specific network + * + * @param network - The network to get the primary RPC URL for + * @returns Primary RPC URL or empty string if none available + */ + getPrimaryRpcUrl(network: Network): string { + const rpcUrls = this.getRpcUrls(network); + return rpcUrls[0] ?? ''; } } diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts deleted file mode 100644 index 6c230323..00000000 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Transaction } from '@metamask/keyring-api'; - -import type { TronKeyringAccount } from '../../entities'; - -export class TransactionsService { - async listTransactions(_account: TronKeyringAccount): Promise { - return []; // TODO: Implement me - } -} diff --git a/merged-packages/tron-wallet-snap/src/utils/interface.ts b/merged-packages/tron-wallet-snap/src/utils/interface.ts deleted file mode 100644 index e457ca92..00000000 --- a/merged-packages/tron-wallet-snap/src/utils/interface.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { - DialogResult, - EntropySource, - GetClientStatusResult, - GetInterfaceStateResult, - Json, - ResolveInterfaceResult, -} from '@metamask/snaps-sdk'; - -import type { Preferences } from '../types/snap'; - -/** - * Gets the state of an interactive interface by its ID. - * - * @param id - The ID for the interface to update. - * @returns An object containing the state of the interface. - */ -export async function getInterfaceState( - id: string, -): Promise { - return snap.request({ - method: 'snap_getInterfaceState', - params: { - id, - }, - }); -} - -/** - * Resolve a dialog using the provided ID. - * - * @param id - The ID for the interface to update. - * @param value - The result to resolve the interface with. - * @returns An object containing the state of the interface. - */ -export async function resolveInterface( - id: string, - value: Json, -): Promise { - return snap.request({ - method: 'snap_resolveInterface', - params: { - id, - value, - }, - }); -} - -/** - * Shows a dialog using the provided ID. - * - * @param id - The ID for the dialog. - * @returns A promise that resolves to a string. - */ -export async function showDialog(id: string): Promise { - return snap.request({ - method: 'snap_dialog', - params: { - id, - }, - }); -} - -/** - * Get preferences from snap. - * - * @returns A promise that resolves to snap preferences. - */ -export async function getPreferences(): Promise { - return snap.request({ - method: 'snap_getPreferences', - }) as Promise; -} - -/** - * Retrieves the client status (locked/unlocked) in this case from MM. - * - * @returns An object containing the status. - */ -export async function getClientStatus(): Promise { - return await snap.request({ - method: 'snap_getClientStatus', - }); -} - -/** - * Schedules a background event. - * - * @param options - The options for the background event. - * @param options.method - The method to call. - * @param options.params - The params to pass to the method. - * @param options.duration - The duration to wait before the event is scheduled. - * @returns A promise that resolves to a string. - */ -export async function scheduleBackgroundEvent({ - method, - params = {}, - duration, -}: { - method: string; - params?: Record; - duration: string; -}): Promise { - return await snap.request({ - method: 'snap_scheduleBackgroundEvent', - params: { - duration, - request: { - method, - params, - }, - }, - }); -} - -/** - * List all entropy sources. - * - * @returns An array of entropy sources. - */ -export async function listEntropySources(): Promise { - return await snap.request({ - method: 'snap_listEntropySources', - }); -} From 583e6f92a0b3a32a9c6569acf029bd1ca549234c Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Tue, 2 Sep 2025 18:06:44 +0100 Subject: [PATCH 012/238] chore: lint fix --- .../src/clients/trongrid/TrongridApiClient.ts | 13 +--- .../src/clients/trongrid/types.ts | 2 - merged-packages/tron-wallet-snap/src/index.ts | 2 +- .../src/services/assets/AssetsService.ts | 74 +++++++++---------- .../src/services/config/index.ts | 1 + .../src/services/connection/Connection.ts | 4 +- 6 files changed, 45 insertions(+), 51 deletions(-) diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts index 9f4a15e1..58d7cc41 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts @@ -1,7 +1,4 @@ -import type { - TronAccount, - TrongridApiResponse, -} from './types'; +import type { TronAccount, TrongridApiResponse } from './types'; import type { Network } from '../../constants'; import type { ConfigProvider } from '../../services/config'; @@ -39,9 +36,8 @@ export class TrongridApiClient { /** * Get account information by address for a specific network * - * @param scope + * @param scope - The network to query (e.g., 'mainnet', 'shasta') * @param address - The TRON address to query - * @param network - The network to query * @returns Promise - Account data in camelCase */ async getAccountInfoByAddress( @@ -62,8 +58,7 @@ export class TrongridApiClient { throw new Error(`HTTP error! status: ${response.status}`); } - const rawData: TrongridApiResponse = - await response.json(); + const rawData: TrongridApiResponse = await response.json(); if (!rawData.success) { throw new Error('API request failed'); @@ -74,7 +69,7 @@ export class TrongridApiClient { } const account = rawData.data[0]; - + if (!account) { throw new Error('No data'); } diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts index 6088fce1..caa058b8 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts @@ -1,5 +1,3 @@ -import { Account } from "tronweb/lib/esm/types"; - /* eslint-disable @typescript-eslint/naming-convention */ export type TrongridApiResponse = { data: T[]; diff --git a/merged-packages/tron-wallet-snap/src/index.ts b/merged-packages/tron-wallet-snap/src/index.ts index 7b0f589f..507d9702 100644 --- a/merged-packages/tron-wallet-snap/src/index.ts +++ b/merged-packages/tron-wallet-snap/src/index.ts @@ -51,5 +51,5 @@ export const onUserInput: OnUserInputHandler = async (params) => userInputHandler.handle(params); export const onActive: OnActiveHandler = async () => { - lifecycleHandler.onActive(); + await lifecycleHandler.onActive(); }; diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 51d7ff37..f93918b7 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -182,21 +182,22 @@ export class AssetsService { }): AssetEntity[] { const { assetV2 } = tronAccountInfo; - const trc10Assets = assetV2?.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc10:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - mint: address, - pubkey: address, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - }; - }); - }) ?? []; + const trc10Assets = + assetV2?.flatMap((tokenObject) => { + return Object.entries(tokenObject).map(([address, balance]) => { + return { + assetType: `${scope}/trc10:${address}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + mint: address, + pubkey: address, + symbol: '', + decimals: 0, + rawAmount: balance, + uiAmount: '0', + }; + }); + }) ?? []; return trc10Assets; } @@ -212,21 +213,22 @@ export class AssetsService { }): AssetEntity[] { const { trc20 } = tronAccountInfo; - const trc20Assets = trc20?.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - mint: address, - pubkey: address, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - }; - }); - }) ?? []; + const trc20Assets = + trc20?.flatMap((tokenObject) => { + return Object.entries(tokenObject).map(([address, balance]) => { + return { + assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + mint: address, + pubkey: address, + symbol: '', + decimals: 0, + rawAmount: balance, + uiAmount: '0', + }; + }); + }) ?? []; return trc20Assets; } @@ -580,7 +582,9 @@ export class AssetsService { fiatExchangeRates: Record; cryptoPrices: Record; }> { - const cryptoAssets = allAssets.filter((asset) => !AssetsService.isFiat(asset)); + const cryptoAssets = allAssets.filter( + (asset) => !AssetsService.isFiat(asset), + ); const [fiatExchangeRates, cryptoPrices] = await Promise.all([ this.#priceApiClient.getFiatExchangeRates(), @@ -648,9 +652,7 @@ export class AssetsService { conversions.forEach((conversion) => { const { from, to } = conversion; - if (!result[from]) { - result[from] = {}; - } + result[from] ??= {}; let fromUsdRate: BigNumber; let toUsdRate: BigNumber; @@ -836,9 +838,7 @@ export class AssetsService { } // Initialize the nested structure for the asset if it doesn't exist - if (!result[assetType]) { - result[assetType] = {}; - } + result[assetType] ??= {}; // Store the market data with the unit as the key result[assetType][unit] = this.#computeMarketData( diff --git a/merged-packages/tron-wallet-snap/src/services/config/index.ts b/merged-packages/tron-wallet-snap/src/services/config/index.ts index 6564831c..82240384 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/index.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/index.ts @@ -1 +1,2 @@ export { ConfigProvider } from './ConfigProvider'; +export type { NetworkConfig } from './ConfigProvider'; diff --git a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts index 5a37777a..22261f7d 100644 --- a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts +++ b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts @@ -2,7 +2,7 @@ import { assert } from '@metamask/superstruct'; import type { Network } from '../../constants'; import { NetworkStruct } from '../../validation/structs'; -import type { ConfigProvider } from '../config'; +import type { ConfigProvider, NetworkConfig } from '../config'; /** * Simplified connection service that can be extended if needed @@ -21,7 +21,7 @@ export class Connection { * @param network - The network to get configuration for * @returns Network configuration */ - getNetworkConfig(network: Network) { + getNetworkConfig(network: Network): NetworkConfig { assert(network, NetworkStruct); return this.#configProvider.getNetworkBy('caip2Id', network); } From 621b1d3d451eecb27a0aeda64b49ae795734732c Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 3 Sep 2025 11:11:11 +0000 Subject: [PATCH 013/238] 1.0.0 --- merged-packages/tron-wallet-snap/CHANGELOG.md | 78 ++++++++++++------- merged-packages/tron-wallet-snap/package.json | 2 +- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 15176ff5..f6382936 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] + +### Uncategorized + +- chore: lint fix +- feat: fully support TRX + token assets balances +- fix: comments +- feat: list trx and trc10 assets +- chore: mock assets +- feat: refactor accounts service +- feat: implement account derivation +- chore: resolve linting problems +- chore: more improvements to the test dApp +- chore: lint all files +- feat: add test dApp +- feat: initial commit + ## [0.17.0] ### Changed @@ -395,33 +412,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...HEAD -[0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 -[0.16.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...v0.16.1 -[0.16.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...v0.16.0 -[0.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...v0.15.0 -[0.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...v0.14.1 -[0.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...v0.14.0 -[0.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...v0.13.0 -[0.12.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...v0.12.1 -[0.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...v0.12.0 -[0.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...v0.11.0 -[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 -[0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 -[0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 -[0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 -[0.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...v0.8.0 -[0.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...v0.7.0 -[0.6.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...v0.6.1 -[0.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...v0.6.0 -[0.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...v0.5.0 -[0.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...v0.4.0 -[0.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...v0.3.0 -[0.2.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...v0.2.5 -[0.2.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...v0.2.4 -[0.2.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...v0.2.3 -[0.2.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...v0.2.2 -[0.2.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...v0.2.1 -[0.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.2...v0.2.0 -[0.1.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.1...v0.1.2 -[0.1.1]: https://github.com/MetaMask/snap-bitcoin-wallet/releases/tag/v0.1.1 +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.17.0...v1.0.0 +[0.17.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.16.1...v0.17.0 +[0.16.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.16.0...v0.16.1 +[0.16.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.15.0...v0.16.0 +[0.15.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.14.1...v0.15.0 +[0.14.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.14.0...v0.14.1 +[0.14.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.13.0...v0.14.0 +[0.13.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.12.1...v0.13.0 +[0.12.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.12.0...v0.12.1 +[0.12.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.11.0...v0.12.0 +[0.11.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.10.0...v0.11.0 +[0.10.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.9.0...v0.10.0 +[0.9.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.8.2...v0.9.0 +[0.8.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.8.1...v0.8.2 +[0.8.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.7.0...v0.8.0 +[0.7.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.6.1...v0.7.0 +[0.6.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.6.0...v0.6.1 +[0.6.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.5...v0.3.0 +[0.2.5]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.4...v0.2.5 +[0.2.4]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.3...v0.2.4 +[0.2.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.1.2...v0.2.0 +[0.1.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v0.1.1 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 4d1f0205..23393256 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "0.1.0", + "version": "1.0.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", From df4efd5a9c424b9bc2c8874f3b3c655eba457aa8 Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Wed, 3 Sep 2025 12:12:50 +0100 Subject: [PATCH 014/238] chore: update changelogs --- merged-packages/tron-wallet-snap/CHANGELOG.md | 433 +----------------- 1 file changed, 3 insertions(+), 430 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index f6382936..6570abca 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -9,437 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] -### Uncategorized - -- chore: lint fix -- feat: fully support TRX + token assets balances -- fix: comments -- feat: list trx and trc10 assets -- chore: mock assets -- feat: refactor accounts service -- feat: implement account derivation -- chore: resolve linting problems -- chore: more improvements to the test dApp -- chore: lint all files -- feat: add test dApp -- feat: initial commit - -## [0.17.0] - -### Changed - -- Synchronize by default ([#492](https://github.com/MetaMask/snap-bitcoin-wallet/pull/492)) - -### Fixed - -- Synchronize accounts with no history ([#491](https://github.com/MetaMask/snap-bitcoin-wallet/pull/491)) - -## [0.16.1] - -### Fixed - -- Typo `onAssetsMarketData` ([#489](https://github.com/MetaMask/snap-bitcoin-wallet/pull/489)) - -## [0.16.0] - -### Added - -- Asset market data on new handler ([#486](https://github.com/MetaMask/snap-bitcoin-wallet/pull/486)) -- Send flow error handling ([#482](https://github.com/MetaMask/snap-bitcoin-wallet/pull/482)) -- Translations ([#483](https://github.com/MetaMask/snap-bitcoin-wallet/pull/483)) - -### Changed - -- Use event value on user input instead of fetching the form state ([#485](https://github.com/MetaMask/snap-bitcoin-wallet/pull/485)) - -## [0.15.0] - -### Added - -- Account index auto increment on creation ([#471](https://github.com/MetaMask/snap-bitcoin-wallet/pull/471)) -- Account selector in send flow ([#479](https://github.com/MetaMask/snap-bitcoin-wallet/pull/479)) -- Market data ([#478](https://github.com/MetaMask/snap-bitcoin-wallet/pull/478)) -- Switch currencies in send flow ([#477](https://github.com/MetaMask/snap-bitcoin-wallet/pull/477)) - -### Changed - -- Align Send flow ([#472](https://github.com/MetaMask/snap-bitcoin-wallet/pull/472)) - -### Fixed - -- Entropy source as part of the `KeyringAccount` options ([#473](https://github.com/MetaMask/snap-bitcoin-wallet/pull/473)) -- Delete account keeping residue in state ([#469](https://github.com/MetaMask/snap-bitcoin-wallet/pull/469)) - -## [0.14.1] - -### Fixed - -- Account name passthrough ([#466](https://github.com/MetaMask/snap-bitcoin-wallet/pull/466)) -- Prevent account deletion key residues ([#466](https://github.com/MetaMask/snap-bitcoin-wallet/pull/466)) - -## [0.14.0] - -### Added - -- Discover accounts ([#464](https://github.com/MetaMask/snap-bitcoin-wallet/pull/464), [#460](https://github.com/MetaMask/snap-bitcoin-wallet/pull/460)) -- Use address types from `keyring-api` ([#461](https://github.com/MetaMask/snap-bitcoin-wallet/pull/461)) - -### Changed - -- Development environment cleanup ([#459](https://github.com/MetaMask/snap-bitcoin-wallet/pull/459)) -- Use `setState` and `getState` instead of `manageState` ([#463](https://github.com/MetaMask/snap-bitcoin-wallet/pull/463)) - -## [0.13.0] - -### Added - -- Multi account ([#457](https://github.com/MetaMask/snap-bitcoin-wallet/pull/457)) -- Historical prices ([#456](https://github.com/MetaMask/snap-bitcoin-wallet/pull/456)) - -## [0.12.1] - -### Added - -- Add unconfirmed transactions to wallet history ([#453](https://github.com/MetaMask/snap-bitcoin-wallet/pull/453)) - -### Changed - -- Reduce bundle size ([#454](https://github.com/MetaMask/snap-bitcoin-wallet/pull/454)) - -## [0.12.0] - -### Added - -- Compress preinstalled Snap during build ([#451](https://github.com/MetaMask/snap-bitcoin-wallet/pull/451)) - -### Changed - -- Disable UTXO protection ([#445](https://github.com/MetaMask/snap-bitcoin-wallet/pull/445)) -- Generate PSBT in send flow ([#448](https://github.com/MetaMask/snap-bitcoin-wallet/pull/448)) - -### Removed - -- Remove SimpleHash ([#447](https://github.com/MetaMask/snap-bitcoin-wallet/pull/447)) - -## [0.11.0] - -### Added - -- Support for `correlationId` and `entropySource` ([#443](https://github.com/MetaMask/snap-bitcoin-wallet/pull/443)) - -### Changed - -- Refactor error handling ([#442](https://github.com/MetaMask/snap-bitcoin-wallet/pull/442)) -- Refactor locale ([#433](https://github.com/MetaMask/snap-bitcoin-wallet/pull/433)) -- Refactor logger ([#437](https://github.com/MetaMask/snap-bitcoin-wallet/pull/437)) -- Reduce bundle size ([#439](https://github.com/MetaMask/snap-bitcoin-wallet/pull/439)) - -### Fixed - -- Discard own outputs from send transactions ([#441](https://github.com/MetaMask/snap-bitcoin-wallet/pull/441)) - -## [0.10.0] - -### Added - -- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408), [#427](https://github.com/MetaMask/snap-bitcoin-wallet/pull/427)) -- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) -- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) -- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) -- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) -- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) -- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) -- Translations ([#403]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) - -### Changed - -- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) -- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) - -### Removed - -- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) - -### Fixed - -- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) - -## [0.9.0] - -### Added - -- Add localized messages ([#348](https://github.com/MetaMask/snap-bitcoin-wallet/pull/348)) -- Add basic Sats protection support ([#337](https://github.com/MetaMask/snap-bitcoin-wallet/pull/337)), ([#284](https://github.com/MetaMask/snap-bitcoin-wallet/pull/284)), ([#349](https://github.com/MetaMask/snap-bitcoin-wallet/pull/349)) - - Using SimpleHash service. - -### Changed - -- **BREAKING:** Provide scopes field to `KeyringAccount` during account creation ([#364](https://github.com/MetaMask/snap-bitcoin-wallet/pull/364)) - - Bump `@metamask/keyring-api` from `^8.1.3` to `^13.0.0`. - - Compatible with `@metamask/eth-snap-keyring@^7.1.0`. -- Support for fee rate caching ([#358](https://github.com/MetaMask/snap-bitcoin-wallet/pull/358)) -- Use Snap UI update context instead of persisting request ([#345](https://github.com/MetaMask/snap-bitcoin-wallet/pull/345)) - - Making the Snap UI slighty more responsive and faster. -- Remove support of Blockchair service ([#298](https://github.com/MetaMask/snap-bitcoin-wallet/pull/298)) - -### Fixed - -- Various UI fixes ([#356](https://github.com/MetaMask/snap-bitcoin-wallet/pull/356)), ([#357](https://github.com/MetaMask/snap-bitcoin-wallet/pull/357)), ([#346](https://github.com/MetaMask/snap-bitcoin-wallet/pull/346)), ([#344](https://github.com/MetaMask/snap-bitcoin-wallet/pull/344)), ([#343](https://github.com/MetaMask/snap-bitcoin-wallet/pull/343)) -- Allow send to happen even when rates are not available ([#350](https://github.com/MetaMask/snap-bitcoin-wallet/pull/350)) - -## [0.8.2] - -### Fixed - -- Remove `localhost` from permissions ([#324](https://github.com/MetaMask/snap-bitcoin-wallet/pull/324)), ([#322](https://github.com/MetaMask/snap-bitcoin-wallet/pull/322)) - -## [0.8.1] - -### Fixed - -- Use `hideSnapBranding` flag when building ([#312](https://github.com/MetaMask/snap-bitcoin-wallet/pull/312)) - -## [0.8.0] - -### Added - -- Add send flow UI ([#281](https://github.com/MetaMask/snap-bitcoin-wallet/pull/281)), ([#309](https://github.com/MetaMask/snap-bitcoin-wallet/pull/309)), ([#308](https://github.com/MetaMask/snap-bitcoin-wallet/pull/308)), ([#305](https://github.com/MetaMask/snap-bitcoin-wallet/pull/305)), ([#304](https://github.com/MetaMask/snap-bitcoin-wallet/pull/304)), ([#289](https://github.com/MetaMask/snap-bitcoin-wallet/pull/289)) - - The send flow can be started using the `startSendTransactionFlow` internal method. - - The UI allows to send an amount to one recipient (according to its balance). - - There is a confirmation screen that summarize everything regarding the transaction (amount, recipient, fee estimation), once confirmed the transaction will be broadcasted to the blockchain. - - Calling the `sendBitcoin` account's method will trigger the confirmation screen too. - -### Changed - -- **BREAKING:** Rename `btc_sendmany` method to `sendBitcoin` ([#303](https://github.com/MetaMask/snap-bitcoin-wallet/pull/303)) - - The `comment` and `subtractFeeFrom` options have been removed too. -- **BREAKING:** Make transactions replaceable by default ([#297](https://github.com/MetaMask/snap-bitcoin-wallet/pull/297)) -- Rename proposed name to "Bitcoin" (was "Bitcoin Manager") ([#283](https://github.com/MetaMask/snap-bitcoin-wallet/pull/283)) -- Adds fee estimation fallback for `QuickNode` ([#269](https://github.com/MetaMask/snap-bitcoin-wallet/pull/269)) - - In some cases, `QuickNode` may fail to process fee estimation. In such instances, we fall back on using fee information directly from the mempool. - - We also added a default minimum fee as the last resort in case everything else failed. - -## [0.7.0] - -### Changed - -- Use `QuickNode` as the main provider ([#250](https://github.com/MetaMask/snap-bitcoin-wallet/pull/250)) -- Workaround `QuickNode` fee estimation for testnet ([#267](https://github.com/MetaMask/snap-bitcoin-wallet/pull/267)) - - We temporarily changed the confirmation target block to a higher block number to make sure the API is not failing with an error. - -### Fixed - -- Fix fee estimation with `QuickNode` ([#266](https://github.com/MetaMask/snap-bitcoin-wallet/pull/266)), ([#261](https://github.com/MetaMask/snap-bitcoin-wallet/pull/261)) - - Properly uses `kvB` instead of `vB`. - - Will **NOT** throw an error if the account has not enough UTXOs when estimating the fees. - -## [0.6.1] - -### Added - -- Add `QuickNode` API client ([#247](https://github.com/MetaMask/snap-bitcoin-wallet/pull/247)) - - This client is not yet used, we still use Blockchair provider for the moment. - -### Changed - -- Bump `@metamask/keyring-api` from `^8.0.2` to `^8.1.3` ([#253](https://github.com/MetaMask/snap-bitcoin-wallet/pull/253)) - - This version is now built slightly differently and is part of the [accounts monorepo](https://github.com/MetaMask/accounts). - -## [0.6.0] - -### Added - -- Display an alert dialog when an error happens during `btc_sendmany` ([#236](https://github.com/MetaMask/snap-bitcoin-wallet/pull/236)) - -### Changed - -- Use similar shorten-address format than the extension ([#238](https://github.com/MetaMask/snap-bitcoin-wallet/pull/238)) - -## [0.5.0] - -### Added - -- Add `getMaxSpendableBalance` RPC endpoint ([#188](https://github.com/MetaMask/snap-bitcoin-wallet/pull/188)) - -### Changed - -- Improve keyring tests coverage ([#220](https://github.com/MetaMask/snap-bitcoin-wallet/pull/220)) - -## [0.4.0] - -### Added - -- Emit account suggested name when creating account ([#210](https://github.com/MetaMask/snap-bitcoin-wallet/pull/210)) - - This name suggestion can then be used by the client to name the account accordingly. - -### Changed - -- Audit 4.6: Pin Bitcoin and cryptographic dependencies ([#205](https://github.com/MetaMask/snap-bitcoin-wallet/pull/205)) - -## [0.3.0] - -### Added - -- Add `estimateFee` RPC endpoint ([#169](https://github.com/MetaMask/snap-bitcoin-wallet/pull/169)) - -## [0.2.5] - -### Added - -- Add reusable error types ([#185](https://github.com/MetaMask/snap-bitcoin-wallet/pull/185)) - -### Changed - -- Use custom `superstruct` validator for `AmountStruct` ([#184](https://github.com/MetaMask/snap-bitcoin-wallet/pull/184)) - -### Fixed - -- Fix overridden message in `MethodNotFoundError` ([#189](https://github.com/MetaMask/snap-bitcoin-wallet/pull/189)) - -## [0.2.4] - -### Fixed - -- Audit 4.5: Show origin on `btc_sendmany` confirmation dialog ([#152](https://github.com/MetaMask/snap-bitcoin-wallet/pull/152)) -- Audit 4.7: Fix potential URL injections in Blockchair API calls ([#132](https://github.com/MetaMask/snap-bitcoin-wallet/pull/132)) -- Audit 4.11: Remove code for creating unsupported P2SHP2WPKH account type ([#118](https://github.com/MetaMask/snap-bitcoin-wallet/pull/118)) -- Audit 4.12: Derive accounts with different HD path by network ([#118](https://github.com/MetaMask/snap-bitcoin-wallet/pull/118)) -- Audit 4.19: Implement keyring method `filterAccountChains` ([#122](https://github.com/MetaMask/snap-bitcoin-wallet/pull/122)) - -## [0.2.3] - -### Changed - -- Change Snap name to "Bitcoin Manager" ([#158](https://github.com/MetaMask/snap-bitcoin-wallet/pull/158)) -- Change local dapp port ([#147](https://github.com/MetaMask/snap-bitcoin-wallet/pull/147)) - -### Fixed - -- Audit 4.2: Remove update account method ([#130](https://github.com/MetaMask/snap-bitcoin-wallet/pull/130)) -- Audit 4.3: Restrict permissions for Portfolio origin ([#131](https://github.com/MetaMask/snap-bitcoin-wallet/pull/131)) -- Audit 4.4: Restrict permissions for MetaMask origin ([#141](https://github.com/MetaMask/snap-bitcoin-wallet/pull/141)) -- Audit 4.8: Ensure that `request.method` in submit request is part of `account.methods` ([#133](https://github.com/MetaMask/snap-bitcoin-wallet/pull/133)) -- Audit 4.9: Validate non-hex string in `hexToBuffer` ([#134](https://github.com/MetaMask/snap-bitcoin-wallet/pull/134)) -- Audit 4.13: Add a safeguard for change output ([#135](https://github.com/MetaMask/snap-bitcoin-wallet/pull/135)) -- Audit 4.14: Rename `txHash` to `signedTransaction` ([#120](https://github.com/MetaMask/snap-bitcoin-wallet/pull/120)) -- Audit 4.20: Validate `headLength` and `tailLength` in `replaceMiddleChar` ([#138](https://github.com/MetaMask/snap-bitcoin-wallet/pull/138)) -- Audit 4.23: Disable logging for production builds ([#124](https://github.com/MetaMask/snap-bitcoin-wallet/pull/124)) -- Audit 4.24: Remove temporary create account endpoint ([#115](https://github.com/MetaMask/snap-bitcoin-wallet/pull/115)) - -## [0.2.2] - -### Changed - -- Remove unused env var ([#148](https://github.com/MetaMask/snap-bitcoin-wallet/pull/148)) - -### Fixed - -- Emit event on account creation ([#153](https://github.com/MetaMask/snap-bitcoin-wallet/pull/153)) - -## [0.2.1] - -### Fixed - -- Remove duplicate validation when getting balances ([#137](https://github.com/MetaMask/snap-bitcoin-wallet/pull/137)) - -## [0.2.0] - -### Added - -- Add 'ramps-dev.portfolio.metamask.io' origin ([#144](https://github.com/MetaMask/snap-bitcoin-wallet/pull/144)) -- Enable `getAccountBalances` method for Portfolio origins ([#126](https://github.com/MetaMask/snap-bitcoin-wallet/pull/126)) -- Implement Keyring API `getAccountBalances` method ([#84](https://github.com/MetaMask/snap-bitcoin-wallet/pull/84)) -- Implement Chain API `getTransactionStatus` method ([#85](https://github.com/MetaMask/snap-bitcoin-wallet/pull/85)) - -### Changed - -- Rename "Bitcoin Manager" to "Bitcoin Wallet" ([#142](https://github.com/MetaMask/snap-bitcoin-wallet/pull/142)) -- Improve `btc_sendMany` implementation ([#97](https://github.com/MetaMask/snap-bitcoin-wallet/pull/97)) -- Update `btc_sendMany` dialogs ([#83](https://github.com/MetaMask/snap-bitcoin-wallet/pull/83)) - -## [0.1.2] - -### Changed - -- fix: update change log format ([#76](https://github.com/MetaMask/bitcoin/pull/76)) -- fix: update package.json ([#76](https://github.com/MetaMask/bitcoin/pull/74)) - -## [0.1.1] - ### Added -- fix: update resp of chainService - boardcastTransaction ([#68](https://github.com/MetaMask/bitcoin/pull/68)) -- feat: add auto install script ([#67](https://github.com/MetaMask/bitcoin/pull/67)) -- feat: add while list domain ([#71](https://github.com/MetaMask/bitcoin/pull/71)) -- chore: re structure ([#66](https://github.com/MetaMask/bitcoin/pull/66)) -- fix: change to keyring state method get wallet ([#62](https://github.com/MetaMask/bitcoin/pull/62)) -- feat: implement keyring api - btc_sendmany ([#65](https://github.com/MetaMask/bitcoin/pull/65)) -- chore: update snap provider instance name ([#69](https://github.com/MetaMask/bitcoin/pull/69)) -- build(deps-dev): bump @metamask/snaps-jest from 7.0.2 to 8.0.0 ([#50](https://github.com/MetaMask/bitcoin/pull/50)) -- feat: add bufferToString method ([#61](https://github.com/MetaMask/bitcoin/pull/61)) -- feat: add chain API - chain_broadcastTransaction ([#57](https://github.com/MetaMask/bitcoin/pull/57)) -- chore: add unit test for get balances ([#58](https://github.com/MetaMask/bitcoin/pull/58)) -- feat: add chain API - chain_getDataForTransaction ([#42](https://github.com/MetaMask/bitcoin/pull/42)) -- feat: add chain API - chain_estimateFees ([#18](https://github.com/MetaMask/bitcoin/pull/18)) -- feat: add keyring API - btc_sendmany skeleton ([#41](https://github.com/MetaMask/bitcoin/pull/41)) -- fix: update btc asset ([#44](https://github.com/MetaMask/bitcoin/pull/44)) -- feat: add ListAccountsButton card ([#43](https://github.com/MetaMask/bitcoin/pull/43)) -- chore: move config to factory ([#35](https://github.com/MetaMask/bitcoin/pull/35)) -- feat: add methods to support satoshi to btc, and btc to satoshi ([#34](https://github.com/MetaMask/bitcoin/pull/34)) -- chore: add commit method in state management ([#32](https://github.com/MetaMask/bitcoin/pull/32)) -- fix: restructure code ([#31](https://github.com/MetaMask/bitcoin/pull/31)) -- fix: remove non ready code ([#30](https://github.com/MetaMask/bitcoin/pull/30)) -- fix: fix snap publish package is not using builded snap config ([#29](https://github.com/MetaMask/bitcoin/pull/29)) -- feat: add api key for blockchair ([#27](https://github.com/MetaMask/bitcoin/pull/27)) -- chore: restructure the code repo to fit to chain api and keyring api structure ([#26](https://github.com/MetaMask/bitcoin/pull/26)) -- fix: refine coding structure ([#25](https://github.com/MetaMask/bitcoin/pull/25)) -- feat: support transactional state management ([#20](https://github.com/MetaMask/bitcoin/pull/20)) -- feat: add permission validation ([#24](https://github.com/MetaMask/bitcoin/pull/24)) -- fix: remove un use code in BaseSnapRpcHandler ([#19](https://github.com/MetaMask/bitcoin/pull/19)) -- feat: add validation on api response ([#17](https://github.com/MetaMask/bitcoin/pull/17)) -- feat: implement chain api - get balances ([#16](https://github.com/MetaMask/bitcoin/pull/16)) -- feat: setup init cd to publish snap to npm public registry ([#15](https://github.com/MetaMask/bitcoin/pull/15)) -- feat: implement chain api skeleton ([#14](https://github.com/MetaMask/bitcoin/pull/14)) -- feat: emit keyring event before state store ([#13](https://github.com/MetaMask/bitcoin/pull/13)) -- feat: implement keyring api ([#12](https://github.com/MetaMask/bitcoin/pull/12)) -- feat: add blockchair ([#11](https://github.com/MetaMask/bitcoin/pull/11)) -- chore: update keyring type and methods permission ([#10](https://github.com/MetaMask/bitcoin/pull/10)) -- chore: update snap icon ([#9](https://github.com/MetaMask/bitcoin/pull/9)) -- fix: the ci pipeline for metamask task issue ([#8](https://github.com/MetaMask/bitcoin/pull/8)) -- build(deps): bump @metamask/keyring-api from 5.1.0 to 6.0.0 ([#6](https://github.com/MetaMask/bitcoin/pull/6)) -- build(deps-dev): bump @metamask/snaps-jest from 6.0.2 to 7.0.2 ([#7](https://github.com/MetaMask/bitcoin/pull/7)) -- feat: add snap unit test ([#1](https://github.com/MetaMask/bitcoin/pull/1)) -- feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) -- feat: init commit +- Initial release of Tron wallet snap +- Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) [Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.0...HEAD -[1.0.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.17.0...v1.0.0 -[0.17.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.16.1...v0.17.0 -[0.16.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.16.0...v0.16.1 -[0.16.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.15.0...v0.16.0 -[0.15.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.14.1...v0.15.0 -[0.14.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.14.0...v0.14.1 -[0.14.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.13.0...v0.14.0 -[0.13.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.12.1...v0.13.0 -[0.12.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.12.0...v0.12.1 -[0.12.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.11.0...v0.12.0 -[0.11.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.10.0...v0.11.0 -[0.10.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.9.0...v0.10.0 -[0.9.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.8.2...v0.9.0 -[0.8.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.8.1...v0.8.2 -[0.8.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.8.0...v0.8.1 -[0.8.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.7.0...v0.8.0 -[0.7.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.6.1...v0.7.0 -[0.6.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.6.0...v0.6.1 -[0.6.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.5.0...v0.6.0 -[0.5.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.4.0...v0.5.0 -[0.4.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.3.0...v0.4.0 -[0.3.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.5...v0.3.0 -[0.2.5]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.4...v0.2.5 -[0.2.4]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.3...v0.2.4 -[0.2.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.2...v0.2.3 -[0.2.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.1...v0.2.2 -[0.2.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.2.0...v0.2.1 -[0.2.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.1.2...v0.2.0 -[0.1.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v0.1.1...v0.1.2 -[0.1.1]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v0.1.1 +[1.0.0]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v1.0.0 \ No newline at end of file From 40bb27516625c4c5d6fa58676f7def49e0825116 Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Wed, 3 Sep 2025 12:16:09 +0100 Subject: [PATCH 015/238] chore: prettier --- merged-packages/tron-wallet-snap/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 6570abca..247f9cb0 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -15,4 +15,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) [Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.0...HEAD -[1.0.0]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v1.0.0 \ No newline at end of file +[1.0.0]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v1.0.0 From d5c6b7e6759f85de66b6c810c8b73c30f9fbe975 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 4 Sep 2025 12:10:31 +0000 Subject: [PATCH 016/238] 1.0.1 --- merged-packages/tron-wallet-snap/CHANGELOG.md | 23 ++++++++++++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 247f9cb0..a6d6b337 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.1] + +### Uncategorized + +- chore: prettier +- chore: update changelogs +- 1.0.0 +- chore: lint fix +- feat: fully support TRX + token assets balances +- fix: comments +- feat: list trx and trc10 assets +- chore: mock assets +- feat: refactor accounts service +- feat: implement account derivation +- chore: resolve linting problems +- chore: more improvements to the test dApp +- chore: lint all files +- feat: add test dApp +- feat: initial commit + ## [1.0.0] ### Added @@ -14,5 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v1.0.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 23393256..e47643c4 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.0.0", + "version": "1.0.1", "description": "A Tron wallet Snap.", "repository": { "type": "git", From 2c83542c055aa9459bf1a4898c17cdf64ce59c05 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 4 Sep 2025 12:12:26 +0000 Subject: [PATCH 017/238] chore: update snap version --- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 565ef343..817129a6 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.1.0", + "version": "1.0.1", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "8q9+CzdymnuXlLShYgcMCdhA1Ulf7BBxacfsu+V5ZpU=", + "shasum": "BUQ5ItMhELC3+JVogDJ/ONyyMBPQhdQE6pr4xznut60=", "location": { "npm": { "filePath": "dist/bundle.js", From 35baa817eac700e5a06d1de4c1bfbfd0645eba24 Mon Sep 17 00:00:00 2001 From: Antonio Regadas Date: Thu, 4 Sep 2025 13:19:13 +0100 Subject: [PATCH 018/238] chore: changelog update --- merged-packages/tron-wallet-snap/CHANGELOG.md | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index a6d6b337..33961a83 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -9,23 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] -### Uncategorized - -- chore: prettier -- chore: update changelogs -- 1.0.0 -- chore: lint fix -- feat: fully support TRX + token assets balances -- fix: comments -- feat: list trx and trc10 assets -- chore: mock assets -- feat: refactor accounts service -- feat: implement account derivation -- chore: resolve linting problems -- chore: more improvements to the test dApp -- chore: lint all files -- feat: add test dApp -- feat: initial commit +### Added + +- Enable corepack ([#17](https://github.com/MetaMask/snap-tron-wallet/pull/17)) ## [1.0.0] From 6f6eb2b5ae989dbdc188400cfb5cf7805c81b967 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 09:42:13 +0100 Subject: [PATCH 019/238] 1.0.2 (#20) This is the release candidate for version 1.0.2. --------- Co-authored-by: github-actions Co-authored-by: Antonio Regadas --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 33961a83..0f0a2018 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.2] + +### Changed + +- Release config update + ## [1.0.1] ### Added @@ -20,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.2...HEAD +[1.0.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v1.0.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index e47643c4..d3b66717 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.0.1", + "version": "1.0.2", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 817129a6..39e03f26 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.0.1", + "version": "1.0.2", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "BUQ5ItMhELC3+JVogDJ/ONyyMBPQhdQE6pr4xznut60=", + "shasum": "0rlgU/w5e07y1LK17XAm59FKRWfwWqVzl0yXAzlAteI=", "location": { "npm": { "filePath": "dist/bundle.js", From 866dce51b0a92a87fc6f0a6401723f66a06832cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Fri, 5 Sep 2025 10:29:51 +0100 Subject: [PATCH 020/238] chore: clean up old values (#22) --- merged-packages/tron-wallet-snap/README.md | 2 +- merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts | 2 +- merged-packages/tron-wallet-snap/src/utils/logger.ts | 2 +- merged-packages/tron-wallet-snap/src/validation/structs.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/README.md b/merged-packages/tron-wallet-snap/README.md index cd8d4026..95e38c87 100644 --- a/merged-packages/tron-wallet-snap/README.md +++ b/merged-packages/tron-wallet-snap/README.md @@ -1,4 +1,4 @@ -# Bitcoin Snap +# Tron Snap ## Configuration diff --git a/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts b/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts index 64dc0a1f..abd81f78 100644 --- a/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts +++ b/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts @@ -4,7 +4,7 @@ import type { EntropySourceId } from '@metamask/keyring-api'; /** * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. * - * @param params - The parameters for the Solana key derivation. + * @param params - The parameters for the Tron key derivation. * @param params.entropySource - The entropy source to use for key derivation. * @param params.path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. * @param params.curve - The elliptic curve to use for key derivation. diff --git a/merged-packages/tron-wallet-snap/src/utils/logger.ts b/merged-packages/tron-wallet-snap/src/utils/logger.ts index 33acd8f0..900e0d2f 100644 --- a/merged-packages/tron-wallet-snap/src/utils/logger.ts +++ b/merged-packages/tron-wallet-snap/src/utils/logger.ts @@ -39,7 +39,7 @@ const withNoopInProduction = }; /** - * A basic logger that wraps the console, extending its functionality to properly log Solana errors. + * A basic logger that wraps the console, extending its functionality to properly log Tron errors. */ const logger: ILogger = { log: withNoopInProduction(console.log), diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.ts b/merged-packages/tron-wallet-snap/src/validation/structs.ts index b224b758..e6867ff0 100644 --- a/merged-packages/tron-wallet-snap/src/validation/structs.ts +++ b/merged-packages/tron-wallet-snap/src/validation/structs.ts @@ -296,7 +296,7 @@ export const Base64Struct = pattern( const DERIVATION_PATH_REGEX = /^m\/44'\/501'/u; /** - * Validates a Solana derivation path following the format: m/44'/501'/... + * Validates a Tron derivation path following the format: m/44'/501'/... */ export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); From 8f83e5f572dd09ace0808ca0c42e7cef621d8bbc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:48:03 +0100 Subject: [PATCH 021/238] 1.0.3 (#23) This is the release candidate for version 1.0.3. --------- Co-authored-by: github-actions Co-authored-by: Antonio Regadas --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 0f0a2018..c927738a 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.3] + +### Changed + +- Clean unnecessary values ([#22](https://github.com/MetaMask/snap-tron-wallet/pull/22)) + ## [1.0.2] ### Changed @@ -26,7 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.3...HEAD +[1.0.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v1.0.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index d3b66717..9a55a097 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.0.2", + "version": "1.0.3", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 39e03f26..df53dfe6 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.0.2", + "version": "1.0.3", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "0rlgU/w5e07y1LK17XAm59FKRWfwWqVzl0yXAzlAteI=", + "shasum": "Yzgo43Y67QNhg6WGyz7r9g0jRRo66HkgVLjM6VKOz4E=", "location": { "npm": { "filePath": "dist/bundle.js", From cebe70cd788a26d5ee630fb37b4de54281076166 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 5 Sep 2025 13:07:13 +0100 Subject: [PATCH 022/238] Better placement for some methods (#24) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/snap/SnapClient.ts | 32 +++++++ .../src/services/accounts/AccountsService.ts | 78 ++++++++++++++++- .../src/utils/deriveTronKeypair.ts | 83 ------------------- .../src/utils/getBip32Entropy.test.ts | 58 ------------- .../src/utils/getBip32Entropy.ts | 32 ------- 6 files changed, 108 insertions(+), 177 deletions(-) delete mode 100644 merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts delete mode 100644 merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.test.ts delete mode 100644 merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index df53dfe6..22d5d8cd 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "Yzgo43Y67QNhg6WGyz7r9g0jRRo66HkgVLjM6VKOz4E=", + "shasum": "a5K1pQ2cjamOHxUnktZhMxVB8R0OxtNgzTuO5Gx+hgs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts index 044d3aef..556734e0 100644 --- a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts @@ -8,12 +8,44 @@ import type { } from '@metamask/snaps-sdk'; import type { Preferences } from '../../types/snap'; +import { EntropySourceId } from '@metamask/keyring-api'; +import { JsonSLIP10Node } from '@metamask/key-tree'; /** * Client for interacting with the Snap API. * Provides methods for managing interfaces, dialogs, preferences, and background events. */ export class SnapClient { + /** + * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. + * + * @param params - The parameters for the Solana key derivation. + * @param params.entropySource - The entropy source to use for key derivation. + * @param params.path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. + * @param params.curve - The elliptic curve to use for key derivation. + * @returns A Promise that resolves to a `SLIP10NodeInterface` object. + */ + async getBip32Entropy({ + entropySource, + path, + curve, + }: { + entropySource?: EntropySourceId | undefined; + path: string[]; + curve: 'secp256k1' | 'ed25519'; + }): Promise { + const node = await snap.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + ...(entropySource ? { source: entropySource } : {}), + }, + }); + + return node; + } + /** * Gets the state of an interactive interface by its ID. * diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index afad1bca..6a6dc4a3 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -1,20 +1,32 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import { assert, integer } from '@metamask/superstruct'; +import { assert, integer, pattern, string } from '@metamask/superstruct'; import type { SnapClient } from '../../clients/snap/SnapClient'; import { asStrictKeyringAccount, type TronKeyringAccount, } from '../../entities'; -import { deriveTronKeypair } from '../../utils/deriveTronKeypair'; import { getLowestUnusedIndex } from '../../utils/getLowestUnusedIndex'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; import type { AssetsService } from '../assets/AssetsService'; import type { ConfigProvider } from '../config'; import type { AccountsRepository } from './AccountsRepository'; import type { CreateAccountOptions } from './types'; +import { hexToBytes } from '@metamask/utils'; +import { TronWeb } from 'tronweb'; + +/** + * Validates a Tron derivation path following the format: m/44'/195'/... + */ +const DERIVATION_PATH_REGEX = /^m\/44'\/195'/u; +export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); + +/** + * Elliptic curve for TRON (same as Ethereum) + */ +const CURVE = 'secp256k1' as const; export class AccountsService { readonly #accountsRepository: AccountsRepository; @@ -47,6 +59,66 @@ export class AccountsService { this.#snapClient = snapClient; } + /** + * Derives a TRON private and public key from a given derivation path using BIP44. + * The derivation path follows the format: m/44'/195'/account'/change/index + * where 195 is TRON's coin type. + * + * @param params - The parameters for the TRON key derivation. + * @param params.entropySource - The entropy source to use for key derivation. + * @param params.derivationPath - The derivation path to use for key derivation. + * @returns A Promise that resolves to the private key, public key, and address. + * @throws {Error} If unable to derive private key or if derivation fails. + * @example + * ```typescript + * const { privateKeyBytes, publicKeyBytes, address } = await deriveTronKeypair({ + * derivationPath: "m/44'/195'/0'/0/0" + * }); + * ``` + */ + async deriveTronKeypair({ + entropySource, + derivationPath, + }: { + entropySource?: EntropySourceId | undefined; + derivationPath: string; + }): Promise<{ + privateKeyBytes: Uint8Array; + publicKeyBytes: Uint8Array; + address: string; + }> { + this.#logger.log({ derivationPath }, 'Generating TRON wallet'); + + assert(derivationPath, DerivationPathStruct); + + const path = derivationPath.split('/'); + + const node = await this.#snapClient.getBip32Entropy({ + entropySource, + path, + curve: CURVE, + }); + + if (!node.privateKey || !node.publicKey) { + throw new Error('Unable to derive private key'); + } + + const privateKeyBytes = hexToBytes(node.privateKey); + const publicKeyBytes = hexToBytes(node.publicKey); + + const address = TronWeb.address.fromPrivateKey(node.privateKey.slice(2)); + + if (!address) { + throw new Error('Unable to derive address'); + } + + return { + privateKeyBytes, + publicKeyBytes, + address, + }; + } + async create( id: string, options?: CreateAccountOptions, @@ -80,7 +152,7 @@ export class AccountsService { return asStrictKeyringAccount(sameAccount); } - const { address: accountAddress } = await deriveTronKeypair({ + const { address: accountAddress } = await this.deriveTronKeypair({ entropySource, derivationPath, }); diff --git a/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts b/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts deleted file mode 100644 index 6ab93605..00000000 --- a/merged-packages/tron-wallet-snap/src/utils/deriveTronKeypair.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { EntropySourceId } from '@metamask/keyring-api'; -import { assert, pattern, string } from '@metamask/superstruct'; -import { hexToBytes } from '@metamask/utils'; -import { TronWeb } from 'tronweb'; - -import { getBip32Entropy } from './getBip32Entropy'; -import logger from './logger'; - -/** - * Validates a Tron derivation path following the format: m/44'/195'/... - */ -const DERIVATION_PATH_REGEX = /^m\/44'\/195'/u; -export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); - -/** - * Elliptic curve for TRON (same as Ethereum) - */ -const CURVE = 'secp256k1' as const; - -/** - * Derives a TRON private and public key from a given derivation path using BIP44. - * The derivation path follows the format: m/44'/195'/account'/change/index - * where 195 is TRON's coin type. - * - * @param params - The parameters for the TRON key derivation. - * @param params.entropySource - The entropy source to use for key derivation. - * @param params.derivationPath - The derivation path to use for key derivation. - * @returns A Promise that resolves to the private key, public key, and address. - * @throws {Error} If unable to derive private key or if derivation fails. - * @example - * ```typescript - * const { privateKeyBytes, publicKeyBytes, address } = await deriveTronKeypair({ - * derivationPath: "m/44'/195'/0'/0/0" - * }); - * ``` - */ -export async function deriveTronKeypair({ - entropySource, - derivationPath, -}: { - entropySource?: EntropySourceId | undefined; - derivationPath: string; -}): Promise<{ - privateKeyBytes: Uint8Array; - publicKeyBytes: Uint8Array; - address: string; -}> { - logger.log({ derivationPath }, 'Generating TRON wallet'); - - assert(derivationPath, DerivationPathStruct); - - const path = derivationPath.split('/'); - - try { - const node = await getBip32Entropy({ - entropySource, - path, - curve: CURVE, - }); - - if (!node.privateKey || !node.publicKey) { - throw new Error('Unable to derive private key'); - } - - const privateKeyBytes = hexToBytes(node.privateKey); - const publicKeyBytes = hexToBytes(node.publicKey); - - const address = TronWeb.address.fromPrivateKey(node.privateKey.slice(2)); - - if (!address) { - throw new Error('Unable to derive address'); - } - - return { - privateKeyBytes, - publicKeyBytes, - address, - }; - } catch (error: any) { - logger.error({ error: error.message }, 'Error deriving TRON keypair'); - throw new Error(`Failed to derive TRON keypair: ${error.message}`); - } -} diff --git a/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.test.ts b/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.test.ts deleted file mode 100644 index 33aad4b5..00000000 --- a/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable no-restricted-globals */ -import { getBip32Entropy } from './getBip32Entropy'; - -describe('getBip32Entropy', () => { - beforeEach(() => { - jest.resetAllMocks(); - Object.defineProperty(global, 'snap', { - value: { - request: jest.fn().mockResolvedValueOnce({ - depth: 2, - masterFingerprint: 3974444335, - parentFingerprint: 2046425034, - index: 2147484149, - curve: 'ed25519', - privateKey: - '0x7acf6060833428c2196ce6e2c5ba5455394602814b9ec6b9bac453b357be7b24', - publicKey: - '0x00389ed03449fbc42a3ec134609b664a50e7a78bad800bad1629113590bfc9af9b', - chainCode: - '0x99d7cef35ae591a92eab31e0007f0199e3bea62d211a219526bf2ae06799886d', - }), - }, - writable: true, - }); - }); - - describe('when we have a valid path and we get a response from the Extension', () => { - it('returns the correct entropy', async () => { - const path = ['m', "44'", "501'"]; - const curve = 'ed25519'; - - await getBip32Entropy({ path, curve }); - - expect(snap.request).toHaveBeenCalledWith({ - method: 'snap_getBip32Entropy', - params: { - path, - curve, - }, - }); - }); - }); - - describe('when we have a valid entropy source and path and we get a response from the Extension', () => { - it('returns the correct entropy', async () => { - const entropySource = '01JR0PT6PNGBN7MRM3MPEVQPC0'; - const path = ['m', "44'", "501'"]; - const curve = 'ed25519'; - - await getBip32Entropy({ entropySource, path, curve }); - - expect(snap.request).toHaveBeenCalledWith({ - method: 'snap_getBip32Entropy', - params: { source: entropySource, path, curve }, - }); - }); - }); -}); diff --git a/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts b/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts deleted file mode 100644 index abd81f78..00000000 --- a/merged-packages/tron-wallet-snap/src/utils/getBip32Entropy.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { JsonSLIP10Node } from '@metamask/key-tree'; -import type { EntropySourceId } from '@metamask/keyring-api'; - -/** - * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. - * - * @param params - The parameters for the Tron key derivation. - * @param params.entropySource - The entropy source to use for key derivation. - * @param params.path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. - * @param params.curve - The elliptic curve to use for key derivation. - * @returns A Promise that resolves to a `SLIP10NodeInterface` object. - */ -export async function getBip32Entropy({ - entropySource, - path, - curve, -}: { - entropySource?: EntropySourceId | undefined; - path: string[]; - curve: 'secp256k1' | 'ed25519'; -}): Promise { - const node = await snap.request({ - method: 'snap_getBip32Entropy', - params: { - path, - curve, - ...(entropySource ? { source: entropySource } : {}), - }, - }); - - return node; -} From ef0962f0b0160c43a5e2d585a099a6feabb09bb7 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 12 Sep 2025 13:38:36 +0100 Subject: [PATCH 023/238] feat: transaction history (#19) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/snap/SnapClient.ts | 6 +- .../src/clients/trongrid/TrongridApiClient.ts | 92 +++- .../src/clients/trongrid/types.ts | 183 +++++--- .../tron-wallet-snap/src/context.ts | 22 +- .../tron-wallet-snap/src/handlers/cronjob.ts | 34 +- .../tron-wallet-snap/src/handlers/keyring.ts | 75 +++- .../src/services/accounts/AccountsService.ts | 89 ++-- .../src/services/assets/AssetsService.ts | 2 +- .../transactions/TransactionsMapper.ts | 414 ++++++++++++++++++ .../transactions/TransactionsRepository.ts | 69 +++ .../transactions/TransactionsService.ts | 103 +++++ 12 files changed, 983 insertions(+), 108 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 22d5d8cd..b0183b2e 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "a5K1pQ2cjamOHxUnktZhMxVB8R0OxtNgzTuO5Gx+hgs=", + "shasum": "reQqG884jfHkhd0BtFonA+SCMNNGMDmHK0Rfwj9irBY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts index 556734e0..debba427 100644 --- a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts @@ -1,3 +1,5 @@ +import type { JsonSLIP10Node } from '@metamask/key-tree'; +import type { EntropySourceId } from '@metamask/keyring-api'; import type { DialogResult, EntropySource, @@ -8,8 +10,6 @@ import type { } from '@metamask/snaps-sdk'; import type { Preferences } from '../../types/snap'; -import { EntropySourceId } from '@metamask/keyring-api'; -import { JsonSLIP10Node } from '@metamask/key-tree'; /** * Client for interacting with the Snap API. @@ -42,7 +42,7 @@ export class SnapClient { ...(entropySource ? { source: entropySource } : {}), }, }); - + return node; } diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts index 58d7cc41..d16772d5 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts @@ -1,4 +1,9 @@ -import type { TronAccount, TrongridApiResponse } from './types'; +import type { + TronAccount, + TrongridApiResponse, + TransactionInfo, + ContractTransactionInfo, +} from './types'; import type { Network } from '../../constants'; import type { ConfigProvider } from '../../services/config'; @@ -34,8 +39,9 @@ export class TrongridApiClient { } /** - * Get account information by address for a specific network + * Get account information by address for a specific network. The returned data will also have assets information. * + * @see https://developers.tron.network/reference/get-account-info-by-address * @param scope - The network to query (e.g., 'mainnet', 'shasta') * @param address - The TRON address to query * @returns Promise - Account data in camelCase @@ -58,7 +64,7 @@ export class TrongridApiClient { throw new Error(`HTTP error! status: ${response.status}`); } - const rawData: TrongridApiResponse = await response.json(); + const rawData: TrongridApiResponse = await response.json(); if (!rawData.success) { throw new Error('API request failed'); @@ -76,4 +82,84 @@ export class TrongridApiClient { return account; } + + /** + * Get native TRX + TRC10 transaction history for an account address. + * + * @see https://developers.tron.network/reference/get-transaction-info-by-account-address + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @param address - The TRON address to query + * @returns Promise - Transaction data in camelCase + */ + async getTransactionInfoByAddress( + scope: Network, + address: string, + ): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const url = `${baseUrl}/v1/accounts/${address}/transactions`; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData: TrongridApiResponse = + await response.json(); + + if (!rawData.success) { + throw new Error('API request failed'); + } + + if (!rawData.data || rawData.data.length === 0) { + throw new Error('No transactions found'); + } + + return rawData.data; + } + + /** + * Get TRC20 transaction history for an account address. + * + * @see https://developers.tron.network/reference/get-trc20-transaction-info-by-account-address + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @param address - The TRON address to query + * @returns Promise - Contract transaction data in camelCase + */ + async getContractTransactionInfoByAddress( + scope: Network, + address: string, + ): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const url = `${baseUrl}/v1/accounts/${address}/transactions/trc20`; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData: TrongridApiResponse = + await response.json(); + + if (!rawData.success) { + throw new Error('API request failed'); + } + + if (!rawData.data || rawData.data.length === 0) { + throw new Error('No transactions found'); + } + + return rawData.data; + } } diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts index caa058b8..191573b2 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ export type TrongridApiResponse = { - data: T[]; + data: T; success: boolean; meta: { at: number; @@ -61,56 +61,131 @@ export type RawTronVote = { vote_count: number; }; -// Mapped types (camelCase) -// export type TronAccount = { -// ownerPermission: TronPermission; -// accountResource: TronAccountResource; -// activePermission: TronPermission[]; -// address: string; -// createTime: number; -// latestOperationTime: number; -// frozenV2: TronFrozenV2[]; -// unfrozenV2: TronUnfrozenV2[]; -// balance: number; -// assetV2: Record[]; -// trc20: Record[]; -// latestConsumeFreeTime: number; -// votes: TronVote[]; -// latestWithdrawTime: number; -// netWindowSize: number; -// netWindowOptimized: boolean; -// }; - -// export type TronPermission = { -// keys: TronKey[]; -// threshold: number; -// permissionName: string; -// operations?: string; -// id?: number; -// type?: string; -// }; - -// export type TronKey = { -// address: string; -// weight: number; -// }; - -// export type TronAccountResource = { -// energyWindowOptimized: boolean; -// energyWindowSize: number; -// }; - -// export type TronFrozenV2 = { -// amount?: number; -// type?: string; -// }; - -// export type TronUnfrozenV2 = { -// unfreezeAmount: number; -// unfreezeExpireTime: number; -// }; - -// export type TronVote = { -// voteAddress: string; -// voteCount: number; -// }; +export type TransactionInfo = { + ret: TransactionResult[]; + signature: string[]; + txID: string; + net_usage: number; + raw_data_hex: string; + net_fee: number; + energy_usage: number; + blockNumber: number; + block_timestamp: number; + energy_fee: number; + energy_usage_total: number; + raw_data: RawTransactionData; + internal_transactions: InternalTransaction[]; +}; + +export type TransactionResult = { + contractRet: string; + fee: number; +}; + +export type RawTransactionData = { + contract: ContractInfo[]; + ref_block_bytes: string; + ref_block_hash: string; + expiration: number; + timestamp: number; + fee_limit?: number; +}; + +// Specific contract types +export type TransferContractInfo = { + parameter: TransferContractParameter; + type: 'TransferContract'; +}; + +export type TransferContractParameter = { + value: TransferContractValue; + type_url: 'type.googleapis.com/protocol.TransferContract'; +}; + +export type TransferContractValue = { + amount: number; + owner_address: string; + to_address: string; +}; + +export type TransferAssetContractInfo = { + parameter: TransferAssetContractParameter; + type: 'TransferAssetContract'; +}; + +export type TransferAssetContractParameter = { + value: TransferAssetContractValue; + type_url: 'type.googleapis.com/protocol.TransferAssetContract'; +}; + +export type TransferAssetContractValue = { + amount: number; + asset_name: string; + owner_address: string; + to_address: string; +}; + +// General contract type (catch-all) +export type GeneralContractInfo = { + parameter: ContractParameter; + type: string; +}; + +// Union type for all contract types +export type ContractInfo = + | TransferContractInfo + | TransferAssetContractInfo + | GeneralContractInfo; + +export type ContractParameter = { + value: ContractValue; + type_url: string; +}; + +export type ContractValue = { + owner_address?: string; + to_address?: string; + unfreeze_balance?: number; + votes?: ContractVote[]; + frozen_balance?: number; + data?: string; + contract_address?: string; + call_value?: number; +}; + +export type ContractVote = { + vote_address: string; + vote_count: number; +}; + +export type InternalTransaction = { + internal_tx_id: string; + data: InternalTransactionData; + to_address: string; + from_address: string; +}; + +export type InternalTransactionData = { + note: string; + rejected: boolean; + call_value?: { + _: number; + }; +}; + +export type ContractTransactionInfo = { + transaction_id: string; + token_info: TokenInfo; + block_timestamp: number; + from: string; + to: string; + type: string; + value: string; +}; + +export type TokenInfo = { + symbol: string; + address: string; + decimals: number; + name: string; +}; diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 9737734b..f32f0c53 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -16,6 +16,8 @@ import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; import type { UnencryptedStateValue } from './services/state/State'; import { State } from './services/state/State'; +import { TransactionsRepository } from './services/transactions/TransactionsRepository'; +import { TransactionsService } from './services/transactions/TransactionsService'; import { WalletService } from './services/wallet/WalletService'; import logger, { noOpLogger } from './utils/logger'; @@ -43,7 +45,11 @@ const state = new State({ const snapClient = new SnapClient(); // Repositories - depend on State +const accountsRepository = new AccountsRepository(state); const assetsRepository = new AssetsRepository(state); +const transactionsRepository = new TransactionsRepository(state); + +// Clients const trongridApiClient = new TrongridApiClient({ configProvider, }); @@ -65,19 +71,24 @@ const assetsService = new AssetsService({ priceApiClient, }); +const transactionsService = new TransactionsService({ + logger, + transactionsRepository, + trongridApiClient, +}); + const walletService = new WalletService({ logger, state, }); -const accountsRepository = new AccountsRepository(state); - const accountsService = new AccountsService({ + logger, accountsRepository, configProvider, - logger, assetsService, snapClient, + transactionsService, }); /** @@ -88,6 +99,7 @@ const assetsHandler = new AssetsHandler({ assetsService, }); const cronHandler = new CronHandler({ + logger, accountsService, snapClient, }); @@ -98,8 +110,10 @@ const lifecycleHandler = new LifecycleHandler({ }); const keyringHandler = new KeyringHandler({ logger, + snapClient, accountsService, assetsService, + transactionsService, }); const rpcHandler = new RpcHandler(); const userInputHandler = new UserInputHandler(); @@ -112,6 +126,7 @@ export type SnapExecutionContext = { assetsService: AssetsService; walletService: WalletService; accountsService: AccountsService; + transactionsService: TransactionsService; tronHttpClient: TronHttpClient; /** * Handlers @@ -132,6 +147,7 @@ const snapContext: SnapExecutionContext = { assetsService, walletService, accountsService, + transactionsService, tronHttpClient, /** * Handlers diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts index c0d8d031..41ca14b6 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts @@ -2,39 +2,55 @@ import type { JsonRpcRequest } from '@metamask/utils'; import type { SnapClient } from '../clients/snap/SnapClient'; import type { AccountsService } from '../services/accounts/AccountsService'; +import type { ILogger } from '../utils/logger'; +import { createPrefixedLogger } from '../utils/logger'; export enum CronMethod { SynchronizeAccounts = 'scheduleRefreshAccounts', } +export enum BackgroundEventMethod { + SyncAccountTransactions = 'onSyncAccountTransactions', +} + export class CronHandler { + readonly #logger: ILogger; + readonly #accountsService: AccountsService; readonly #snapClient: SnapClient; constructor({ + logger, accountsService, snapClient, }: { + logger: ILogger; accountsService: AccountsService; snapClient: SnapClient; }) { + this.#logger = createPrefixedLogger(logger, '[⏰ CronHandler]'); this.#accountsService = accountsService; this.#snapClient = snapClient; } async handle(request: JsonRpcRequest): Promise { - const { method } = request; + const { method, params } = request; const { active } = await this.#snapClient.getClientStatus(); if (!active) { return; } - switch (method as CronMethod) { + switch (method as CronMethod | BackgroundEventMethod) { case CronMethod.SynchronizeAccounts: await this.synchronizeAccounts(); break; + case BackgroundEventMethod.SyncAccountTransactions: + await this.synchronizeAccountTransactions( + (params as { accountId: string })?.accountId, + ); + break; default: throw new Error(`Unknown cronjob method: ${method}`); } @@ -45,6 +61,8 @@ export class CronHandler { * This can be called by cron jobs to keep data fresh. */ async synchronizeAccounts(): Promise { + this.#logger.info('Synchronizing accounts...'); + const { active } = await this.#snapClient.getClientStatus(); if (!active) { @@ -59,4 +77,16 @@ export class CronHandler { duration: '30s', }); } + + async synchronizeAccountTransactions(accountId: string): Promise { + this.#logger.info(`Synchronizing transactions for account ${accountId}...`); + + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return; + } + + await this.#accountsService.synchronizeTransactions([account]); + } } diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index da092633..80a25ece 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -29,9 +29,12 @@ import type { import { sortBy } from 'lodash'; import type { TronKeyringAccount } from '../entities'; +import { BackgroundEventMethod } from './cronjob'; +import type { SnapClient } from '../clients/snap/SnapClient'; import type { AccountsService } from '../services/accounts/AccountsService'; import type { CreateAccountOptions } from '../services/accounts/types'; import type { AssetsService } from '../services/assets/AssetsService'; +import type { TransactionsService } from '../services/transactions/TransactionsService'; import { withCatchAndThrowSnapError } from '../utils/errors'; import { createPrefixedLogger, type ILogger } from '../utils/logger'; import { @@ -49,22 +52,32 @@ import { export class KeyringHandler implements Keyring { readonly #logger: ILogger; + readonly #snapClient: SnapClient; + readonly #accountsService: AccountsService; readonly #assetsService: AssetsService; + readonly #transactionsService: TransactionsService; + constructor({ logger, + snapClient, accountsService, assetsService, + transactionsService, }: { logger: ILogger; + snapClient: SnapClient; accountsService: AccountsService; assetsService: AssetsService; + transactionsService: TransactionsService; }) { this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); + this.#snapClient = snapClient; this.#accountsService = accountsService; this.#assetsService = assetsService; + this.#transactionsService = transactionsService; } async handle(origin: string, request: JsonRpcRequest): Promise { @@ -117,7 +130,15 @@ export class KeyringHandler implements Keyring { try { const account = await this.#accountsService.create(id, options); - await this.#accountsService.synchronize([account]); + /** + * For transactions we don't need to be in a hurry, so we schedule + * a background event to sync them right after. + */ + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SyncAccountTransactions, + params: { accountId: id }, + duration: 'PT1S', + }); return account; } catch (error: any) { @@ -149,6 +170,15 @@ export class KeyringHandler implements Keyring { } } + /** + * Fetch transactions from the Snap's state. + * + * @param accountId - The id of the account. + * @param pagination - The pagination options. + * @param pagination.limit - The limit of the transactions to fetch. + * @param pagination.next - The next signature to fetch from. + * @returns The transactions for the given account. + */ async listAccountTransactions( accountId: string, pagination: Pagination, @@ -156,10 +186,45 @@ export class KeyringHandler implements Keyring { data: Transaction[]; next: string | null; }> { - return { - data: [], - next: null, - }; + try { + this.#logger.info('Listing account transactions...'); + const { limit, next } = pagination; + + const keyringAccount = await this.getAccount(accountId); + + if (!keyringAccount) { + throw new Error('Account not found'); + } + + const transactions = await this.#transactionsService.findByAccounts([ + keyringAccount, + ]); + + // Find the starting index based on the 'next' signature + const startIndex = next + ? transactions.findIndex((tx) => tx.id === next) + : 0; + + // Get transactions from startIndex to startIndex + limit + const accountTransactions = transactions.slice( + startIndex, + startIndex + limit, + ); + + // Determine the next signature for pagination + const hasMore = startIndex + pagination.limit < transactions.length; + const nextSignature = hasMore + ? (transactions[startIndex + pagination.limit]?.id ?? null) + : null; + + return { + data: accountTransactions, + next: nextSignature, + }; + } catch (error: any) { + this.#logger.error({ error }, 'Error listing account transactions'); + throw error; + } } async discoverAccounts?( diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 6a6dc4a3..7f6ca455 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -2,6 +2,8 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { assert, integer, pattern, string } from '@metamask/superstruct'; +import { hexToBytes } from '@metamask/utils'; +import { TronWeb } from 'tronweb'; import type { SnapClient } from '../../clients/snap/SnapClient'; import { @@ -14,8 +16,7 @@ import type { AssetsService } from '../assets/AssetsService'; import type { ConfigProvider } from '../config'; import type { AccountsRepository } from './AccountsRepository'; import type { CreateAccountOptions } from './types'; -import { hexToBytes } from '@metamask/utils'; -import { TronWeb } from 'tronweb'; +import type { TransactionsService } from '../transactions/TransactionsService'; /** * Validates a Tron derivation path following the format: m/44'/195'/... @@ -37,6 +38,8 @@ export class AccountsService { readonly #assetsService: AssetsService; + readonly #transactionsService: TransactionsService; + readonly #snapClient: SnapClient; constructor({ @@ -45,17 +48,20 @@ export class AccountsService { logger, assetsService, snapClient, + transactionsService, }: { accountsRepository: AccountsRepository; configProvider: ConfigProvider; logger: ILogger; assetsService: AssetsService; snapClient: SnapClient; + transactionsService: TransactionsService; }) { this.#logger = createPrefixedLogger(logger, '[🔑 AccountsService]'); this.#configProvider = configProvider; this.#accountsRepository = accountsRepository; this.#assetsService = assetsService; + this.#transactionsService = transactionsService; this.#snapClient = snapClient; } @@ -93,30 +99,30 @@ export class AccountsService { const path = derivationPath.split('/'); - const node = await this.#snapClient.getBip32Entropy({ - entropySource, - path, - curve: CURVE, - }); + const node = await this.#snapClient.getBip32Entropy({ + entropySource, + path, + curve: CURVE, + }); - if (!node.privateKey || !node.publicKey) { - throw new Error('Unable to derive private key'); - } + if (!node.privateKey || !node.publicKey) { + throw new Error('Unable to derive private key'); + } - const privateKeyBytes = hexToBytes(node.privateKey); - const publicKeyBytes = hexToBytes(node.publicKey); + const privateKeyBytes = hexToBytes(node.privateKey); + const publicKeyBytes = hexToBytes(node.publicKey); - const address = TronWeb.address.fromPrivateKey(node.privateKey.slice(2)); + const address = TronWeb.address.fromPrivateKey(node.privateKey.slice(2)); - if (!address) { - throw new Error('Unable to derive address'); - } + if (!address) { + throw new Error('Unable to derive address'); + } - return { - privateKeyBytes, - publicKeyBytes, - address, - }; + return { + privateKeyBytes, + publicKeyBytes, + address, + }; } async create( @@ -189,6 +195,12 @@ export class AccountsService { const keyringAccount = asStrictKeyringAccount(tronKeyringAccount); + /** + * Fetch the account's assets before we send it to the UI so that + * it's loaded with data already in place. + */ + await this.synchronizeAssets([keyringAccount]); + await emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { /** * We can't pass the `keyringAccount` object because it contains the index @@ -233,16 +245,21 @@ export class AccountsService { return this.#accountsRepository.delete(id); } - async synchronize(accounts: KeyringAccount[]): Promise { + /** + * Synchronizes only assets for the given accounts. + * This method can be called independently to sync assets without syncing transactions. + * + * @param accounts - The accounts to synchronize assets for. + */ + async synchronizeAssets(accounts: KeyringAccount[]): Promise { const scopes = this.#configProvider.get().activeNetworks; const combinations = accounts.flatMap((account) => scopes.map((scope) => ({ account, scope })), ); - // Synchronize assets const assetResponses = await Promise.allSettled( combinations.map(async ({ account, scope }) => { - return this.#assetsService.fetchAssetsAndBalancesByAccount( + return this.#assetsService.fetchAssetsAndBalancesForAccount( scope, account, ); @@ -256,33 +273,33 @@ export class AccountsService { await this.#assetsService.saveMany(assets); } - /** - * Synchronizes only assets for the given accounts. - * This method can be called independently to sync assets without syncing transactions. - * - * @param accounts - The accounts to synchronize assets for. - */ - async synchronizeAssets(accounts: KeyringAccount[]): Promise { + async synchronizeTransactions(accounts: KeyringAccount[]): Promise { const scopes = this.#configProvider.get().activeNetworks; const combinations = accounts.flatMap((account) => scopes.map((scope) => ({ account, scope })), ); - // Synchronize assets only - const assetResponses = await Promise.allSettled( + const transactionResponses = await Promise.allSettled( combinations.map(async ({ account, scope }) => { - return this.#assetsService.fetchAssetsAndBalancesByAccount( + return this.#transactionsService.fetchTransactionsForAccount( scope, account, ); }), ); - const assets = assetResponses.flatMap((response) => + const transactions = transactionResponses.flatMap((response) => response.status === 'fulfilled' ? response.value : [], ); - await this.#assetsService.saveMany(assets); + await this.#transactionsService.saveMany(transactions); + } + + async synchronize(accounts: KeyringAccount[]): Promise { + await Promise.all([ + this.synchronizeAssets(accounts), + this.synchronizeTransactions(accounts), + ]); } #getLowestUnusedKeyringAccountIndex( diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index f93918b7..a9d5e661 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -78,7 +78,7 @@ export class AssetsService { this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; } - async fetchAssetsAndBalancesByAccount( + async fetchAssetsAndBalancesForAccount( scope: Network, account: KeyringAccount, ): Promise { diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts new file mode 100644 index 00000000..a517d6b0 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts @@ -0,0 +1,414 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { TronWeb } from 'tronweb'; + +import type { + ContractTransactionInfo, + TransactionInfo, + TransferContractInfo, + TransferAssetContractInfo, +} from '../../clients/trongrid/types'; +import type { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities'; + +export class TransactionMapper { + /** + * Maps a TransferContract (native TRX transfer) transaction. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.trongridTransaction - The raw transaction data from Trongrid. + * @returns The mapped Transaction or null if the transaction is not supported. + */ + static #mapTransferContract({ + scope, + account, + trongridTransaction, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + }): Transaction | null { + const firstContract = trongridTransaction.raw_data + .contract[0] as TransferContractInfo; + const contractValue = firstContract.parameter.value; + + const from = TronWeb.address.fromHex(contractValue.owner_address); + const to = TronWeb.address.fromHex(contractValue.to_address); + // Convert from sun to TRX (divide by 10^6) + const amountInSun = contractValue.amount; + const amountInTrx = (amountInSun / 1_000_000).toString(); + const fee = trongridTransaction.ret[0]?.fee?.toString() ?? '0'; + // Convert fee from sun to TRX as well + const feeInTrx = (parseInt(fee, 10) / 1_000_000).toString(); + + let type: 'send' | 'receive' | 'unknown'; + + if (from === account.address) { + type = 'send'; + } else if (to === account.address) { + type = 'receive'; + } else { + type = 'unknown'; + } + + return { + type, + id: trongridTransaction.txID, + from: [ + { + address: from as any, + asset: { + unit: 'TRX', + type: `${scope}/slip44:195`, + amount: amountInTrx, + fungible: true, + }, + }, + ], + to: [ + { + address: to, + asset: { + unit: 'TRX', + type: `${scope}/slip44:195`, + amount: amountInTrx, + fungible: true, + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: trongridTransaction.block_timestamp, + }, + ], + chain: scope, + status: 'confirmed', + account: account.id, + timestamp: trongridTransaction.block_timestamp, + fees: [ + { + type: 'base', + asset: { + unit: 'TRX', + type: `${scope}/slip44:195`, + amount: feeInTrx, + fungible: true, + }, + }, + ], + }; + } + + /** + * Maps a TransferAssetContract (TRC10 token transfer) transaction. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.trongridTransaction - The raw transaction data from Trongrid. + * @returns The mapped Transaction or null if the transaction is not supported. + */ + static #mapTransferAssetContract({ + scope, + account, + trongridTransaction, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + }): Transaction | null { + const firstContract = trongridTransaction.raw_data + .contract[0] as TransferAssetContractInfo; + const contractValue = firstContract.parameter.value; + + const from = TronWeb.address.fromHex(contractValue.owner_address); + const to = TronWeb.address.fromHex(contractValue.to_address); + // Convert from smallest unit to human-readable amount (TRC10 typically uses 6 decimals) + const amountInSmallestUnit = contractValue.amount; + const amountInReadableUnit = (amountInSmallestUnit / 1_000_000).toString(); + const assetName = contractValue.asset_name; + const fee = trongridTransaction.ret[0]?.fee?.toString() ?? '0'; + // Convert fee from sun to TRX + const feeInTrx = (parseInt(fee, 10) / 1_000_000).toString(); + + let type: 'send' | 'receive' | 'unknown'; + + if (from === account.address) { + type = 'send'; + } else if (to === account.address) { + type = 'receive'; + } else { + type = 'unknown'; + } + + return { + type, + id: trongridTransaction.txID, + from: [ + { + address: from as any, + asset: { + unit: assetName, // Using the actual TRC10 asset name + type: `${scope}/slip44:195`, + amount: amountInReadableUnit, + fungible: true, + }, + }, + ], + to: [ + { + address: to, + asset: { + unit: assetName, // Using the actual TRC10 asset name + type: `${scope}/slip44:195`, + amount: amountInReadableUnit, + fungible: true, + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: trongridTransaction.block_timestamp, + }, + ], + chain: scope, + status: 'confirmed', + account: account.id, + timestamp: trongridTransaction.block_timestamp, + fees: [ + { + type: 'base', + asset: { + unit: 'TRX', + type: `${scope}/slip44:195`, + amount: feeInTrx, + fungible: true, + }, + }, + ], + }; + } + + /** + * Maps a TriggerSmartContract transaction, which can be a TRC20 transfer. + * Uses TRC20 assistance data when available for enhanced parsing. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.trongridTransaction - The raw transaction data from Trongrid. + * @param params.trc20AssistanceData - Optional TRC20 data for this transaction ID. + * @returns The mapped Transaction or null if the transaction is not supported. + */ + static #mapTriggerSmartContract({ + scope, + account, + trongridTransaction, + trc20AssistanceData, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + trc20AssistanceData?: ContractTransactionInfo; + }): Transaction | null { + // If no TRC20 assistance data is available, we can't parse this smart contract interaction + // In the future, we could add generic smart contract parsing here + if (!trc20AssistanceData) { + return null; + } + + const { from } = trc20AssistanceData; + const { to } = trc20AssistanceData; + + // Convert from smallest unit to human-readable amount using token decimals + const valueInSmallestUnit = trc20AssistanceData.value; + const { decimals } = trc20AssistanceData.token_info; + const divisor = Math.pow(10, decimals); + const valueInReadableUnit = ( + parseFloat(valueInSmallestUnit) / divisor + ).toString(); + + let type: 'send' | 'receive' | 'unknown'; + if (from === account.address) { + type = 'send'; + } else if (to === account.address) { + type = 'receive'; + } else { + type = 'unknown'; + } + + // Calculate total fees from raw transaction data + const totalFee = trongridTransaction.ret[0]?.fee ?? 0; + const netFee = trongridTransaction.net_fee ?? 0; + const energyFee = trongridTransaction.energy_fee ?? 0; + const combinedFee = totalFee + netFee + energyFee; + const feeInTrx = (combinedFee / 1_000_000).toString(); + + return { + type, + id: trc20AssistanceData.transaction_id, + from: [ + { + address: from as any, + asset: { + unit: trc20AssistanceData.token_info.symbol, + type: `${scope}/slip44:195`, + amount: valueInReadableUnit, + fungible: true, + }, + }, + ], + to: [ + { + address: to, + asset: { + unit: trc20AssistanceData.token_info.symbol, + type: `${scope}/slip44:195`, + amount: valueInReadableUnit, + fungible: true, + }, + }, + ], + events: [ + { + status: 'confirmed', + timestamp: trc20AssistanceData.block_timestamp, + }, + ], + chain: scope, + status: 'confirmed', + account: account.id, + timestamp: trc20AssistanceData.block_timestamp, + fees: [ + { + type: 'base', + asset: { + unit: 'TRX', + type: `${scope}/slip44:195`, + amount: feeInTrx, + fungible: true, + }, + }, + ], + }; + } + + /** + * Maps a raw transaction using the appropriate mapping method based on contract type. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.trongridTransaction - The raw transaction data from Trongrid. + * @param params.trc20AssistanceData - Optional TRC20 data for this transaction ID. + * @returns The mapped Transaction or null if the transaction is not supported. + */ + static mapTransaction({ + scope, + account, + trongridTransaction, + trc20AssistanceData, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + trc20AssistanceData?: ContractTransactionInfo; + }): Transaction | null { + /** + * Cheat Sheet of "raw_data" > "contract" > "type" + * + * TransferContract - Native TRX transfer + * TransferAssetContract - TRC10 token transfer + * TriggerSmartContract - Smart contract interaction (can be TRC20 transfer) + */ + + // ASSUME: Only use the first contract to classify the transaction type. + const firstContract = trongridTransaction.raw_data.contract?.[0]; + const contractType = firstContract?.type; + + if (!firstContract || !contractType) { + return null; + } + + switch (contractType) { + case 'TransferContract': + return this.#mapTransferContract({ + scope, + account, + trongridTransaction, + }); + + case 'TransferAssetContract': + return this.#mapTransferAssetContract({ + scope, + account, + trongridTransaction, + }); + + case 'TriggerSmartContract': + return this.#mapTriggerSmartContract({ + scope, + account, + trongridTransaction, + trc20AssistanceData, + }); + + default: + // Unsupported transaction type + return null; + } + } + + /** + * Maps raw transaction data with TRC20 assistance data to create a complete transaction mapping. + * This method treats raw transactions as the primary source and uses TRC20 data as assistance. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.rawTransactions - Array of raw transaction data (primary source). + * @param params.trc20Transactions - Array of TRC20 transaction data (assistance). + * @returns Array of mapped Transactions. + */ + static mapTransactions({ + scope, + account, + rawTransactions, + trc20Transactions, + }: { + scope: Network; + account: TronKeyringAccount; + rawTransactions: TransactionInfo[]; + trc20Transactions: ContractTransactionInfo[]; + }): Transaction[] { + // Create a map of TRC20 transactions by transaction_id for quick lookup as assistance data + const trc20AssistanceMap = new Map(); + for (const trc20Tx of trc20Transactions) { + trc20AssistanceMap.set(trc20Tx.transaction_id, trc20Tx); + } + + // Process each raw transaction as the primary source + const transactions: Transaction[] = []; + for (const rawTx of rawTransactions) { + // Check if we have TRC20 assistance data for this transaction + const trc20AssistanceData = trc20AssistanceMap.get(rawTx.txID); + + // Map the raw transaction using assistance data when available + const mappedTx = this.mapTransaction({ + scope, + account, + trongridTransaction: rawTx, + trc20AssistanceData, + }); + + if (mappedTx) { + transactions.push(mappedTx); + } + } + + return transactions; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.ts new file mode 100644 index 00000000..64aecd84 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.ts @@ -0,0 +1,69 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { chain } from 'lodash'; + +import type { State, UnencryptedStateValue } from '../state/State'; + +export class TransactionsRepository { + readonly #state: State; + + readonly #stateKey = 'transactions'; + + constructor(state: State) { + this.#state = state; + } + + async getAll(): Promise { + const transactionsByAccount = await this.#state.getKey< + UnencryptedStateValue['transactions'] + >(this.#stateKey); + + return Object.values(transactionsByAccount ?? {}).flat(); + } + + async findByAccountId(accountId: string): Promise { + const transactions = await this.#state.getKey( + `${this.#stateKey}.${accountId}`, + ); + + return transactions ?? []; + } + + async save(transaction: Transaction): Promise { + await this.saveMany([transaction]); + } + + async saveMany(transactions: Transaction[]): Promise { + // Optimize the sate operations by reading and writing to the state only once + await this.#state.update((state) => { + const allTransactionsByAccount = state[this.#stateKey]; + + transactions.forEach((transaction) => { + const signature = transaction.id; + const accountId = transaction.account; + const existingTransactionsForAccount = + allTransactionsByAccount[accountId] ?? []; + + // Avoid duplicates. If a transaction with the same signature already exists, override it. + const sameSignatureTransactionIndex = + existingTransactionsForAccount.findIndex((tx) => tx.id === signature); + + if (sameSignatureTransactionIndex !== -1) { + existingTransactionsForAccount[sameSignatureTransactionIndex] = + transaction; + } + + const updatedTransactions = chain([ + ...existingTransactionsForAccount, + transaction, + ]) + .uniqBy('id') + .sortBy((item) => -(item.timestamp ?? 0)) // Sort by timestamp in descending order + .value(); + + state[this.#stateKey][accountId] = updatedTransactions; + }); + + return state; + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts new file mode 100644 index 00000000..26a1615c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts @@ -0,0 +1,103 @@ +import type { KeyringAccount, Transaction } from '@metamask/keyring-api'; +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { groupBy } from 'lodash'; + +import { TransactionMapper } from './TransactionsMapper'; +import type { TransactionsRepository } from './TransactionsRepository'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; + +export class TransactionsService { + readonly #logger: ILogger; + + readonly #transactionsRepository: TransactionsRepository; + + readonly #trongridApiClient: TrongridApiClient; + + constructor({ + logger, + transactionsRepository, + trongridApiClient, + }: { + logger: ILogger; + transactionsRepository: TransactionsRepository; + trongridApiClient: TrongridApiClient; + }) { + this.#logger = createPrefixedLogger(logger, '[🧾 TransactionsService]'); + this.#transactionsRepository = transactionsRepository; + this.#trongridApiClient = trongridApiClient; + } + + async fetchTransactionsForAccount( + scope: Network, + account: KeyringAccount, + ): Promise { + this.#logger.info( + `Fetching transactions for account ${account.address} on network ${scope}...`, + ); + /** + * Raw transactions are the primary source containing complete transaction history + * TRC20 transactions provide assistance data for enhanced smart contract parsing + */ + const [tronRawTransactions, tronTrc20Transactions] = await Promise.all([ + this.#trongridApiClient.getTransactionInfoByAddress( + scope, + account.address, + ), + this.#trongridApiClient.getContractTransactionInfoByAddress( + scope, + account.address, + ), + ]); + + this.#logger.info( + `Fetched ${tronRawTransactions.length} raw transactions and ${tronTrc20Transactions.length} TRC20 assistance data for account ${account.address} on network ${scope}.`, + ); + + /** + * Map transactions using raw data as primary source with TRC20 assistance + * Raw transactions -> All transaction types (TRX, TRC10, TRC20, other smart contracts) + * TRC20 assistance -> Enhanced parsing for TriggerSmartContract transactions + */ + const transactions = TransactionMapper.mapTransactions({ + scope, + account: account as TronKeyringAccount, + rawTransactions: tronRawTransactions, + trc20Transactions: tronTrc20Transactions, + }); + + this.#logger.info( + `Mapped ${transactions.length} transactions for account ${account.address} on network ${scope}.`, + ); + + return transactions; + } + + async findByAccounts(accounts: TronKeyringAccount[]): Promise { + const transactions = await Promise.all( + accounts.map(async (account) => + this.#transactionsRepository.findByAccountId(account.id), + ), + ); + + return transactions.flat(); + } + + async save(transaction: Transaction): Promise { + await this.saveMany([transaction]); + } + + async saveMany(transactions: Transaction[]): Promise { + await this.#transactionsRepository.saveMany(transactions); + + const transactionsByAccountId = groupBy(transactions, 'account'); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { + transactions: transactionsByAccountId, + }); + } +} From ab1b2da6be1c692cf51d804012b85dfbbf68bd8b Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 15 Sep 2025 17:17:08 +0100 Subject: [PATCH 024/238] feat: support Energy and Bandwidth as transaction history fee (#25) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/snap/SnapClient.ts | 10 +- .../tron-wallet-snap/src/constants/index.ts | 73 +++ .../transactions/TransactionsMapper.test.ts | 296 ++++++++++++ .../transactions/TransactionsMapper.ts | 154 ++++--- .../transactions/TransactionsService.test.ts | 426 ++++++++++++++++++ .../transactions/mocks/contract-info.json | 51 +++ .../transactions/mocks/native-transfer.json | 40 ++ .../transactions/mocks/trc10-transfer.json | 41 ++ .../transactions/mocks/trc20-transfer.json | 41 ++ 10 files changed, 1068 insertions(+), 66 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/mocks/contract-info.json create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/mocks/native-transfer.json create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc10-transfer.json create mode 100644 merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc20-transfer.json diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index b0183b2e..4f6b0a73 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "reQqG884jfHkhd0BtFonA+SCMNNGMDmHK0Rfwj9irBY=", + "shasum": "EEnV9UPTWHRhqZhfCqWbG/YwvYtRAiJwCGe/mAXUL7I=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts index debba427..b1758fc9 100644 --- a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts @@ -34,7 +34,7 @@ export class SnapClient { path: string[]; curve: 'secp256k1' | 'ed25519'; }): Promise { - const node = await snap.request({ + return snap.request({ method: 'snap_getBip32Entropy', params: { path, @@ -42,8 +42,6 @@ export class SnapClient { ...(entropySource ? { source: entropySource } : {}), }, }); - - return node; } /** @@ -113,7 +111,7 @@ export class SnapClient { * @returns An object containing the status. */ async getClientStatus(): Promise { - return await snap.request({ + return snap.request({ method: 'snap_getClientStatus', }); } @@ -136,7 +134,7 @@ export class SnapClient { params?: Record; duration: string; }): Promise { - return await snap.request({ + return snap.request({ method: 'snap_scheduleBackgroundEvent', params: { duration, @@ -154,7 +152,7 @@ export class SnapClient { * @returns An array of entropy sources. */ async listEntropySources(): Promise { - return await snap.request({ + return snap.request({ method: 'snap_listEntropySources', }); } diff --git a/merged-packages/tron-wallet-snap/src/constants/index.ts b/merged-packages/tron-wallet-snap/src/constants/index.ts index 9de6501c..f79513e4 100644 --- a/merged-packages/tron-wallet-snap/src/constants/index.ts +++ b/merged-packages/tron-wallet-snap/src/constants/index.ts @@ -16,29 +16,94 @@ export enum KnownCaip19Id { TrxLocalnet = `${Network.Localnet}/slip44:195`, UsdtMainnet = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, + + // Tron Resource Assets + EnergyMainnet = `${Network.Mainnet}/slip44:energy`, + EnergyNile = `${Network.Nile}/slip44:energy`, + EnergyShasta = `${Network.Shasta}/slip44:energy`, + EnergyLocalnet = `${Network.Localnet}/slip44:energy`, + + BandwidthMainnet = `${Network.Mainnet}/slip44:bandwidth`, + BandwidthNile = `${Network.Nile}/slip44:bandwidth`, + BandwidthShasta = `${Network.Shasta}/slip44:bandwidth`, + BandwidthLocalnet = `${Network.Localnet}/slip44:bandwidth`, } export const TokenMetadata = { [KnownCaip19Id.TrxMainnet]: { + id: KnownCaip19Id.TrxMainnet, name: 'Tron', symbol: 'TRX', decimals: 6, }, [KnownCaip19Id.TrxNile]: { + id: KnownCaip19Id.TrxNile, name: 'Tron', symbol: 'TRX', decimals: 6, }, [KnownCaip19Id.TrxShasta]: { + id: KnownCaip19Id.TrxShasta, name: 'Tron', symbol: 'TRX', decimals: 6, }, [KnownCaip19Id.TrxLocalnet]: { + id: KnownCaip19Id.TrxLocalnet, name: 'Tron', symbol: 'TRX', decimals: 6, }, + // Energy resource metadata + [KnownCaip19Id.EnergyMainnet]: { + id: KnownCaip19Id.EnergyMainnet, + name: 'Tron Energy', + symbol: 'ENERGY', + decimals: 0, + }, + [KnownCaip19Id.EnergyNile]: { + id: KnownCaip19Id.EnergyNile, + name: 'Tron Energy', + symbol: 'ENERGY', + decimals: 0, + }, + [KnownCaip19Id.EnergyShasta]: { + id: KnownCaip19Id.EnergyShasta, + name: 'Tron Energy', + symbol: 'ENERGY', + decimals: 0, + }, + [KnownCaip19Id.EnergyLocalnet]: { + id: KnownCaip19Id.EnergyLocalnet, + name: 'Tron Energy', + symbol: 'ENERGY', + decimals: 0, + }, + // Bandwidth resource metadata + [KnownCaip19Id.BandwidthMainnet]: { + id: KnownCaip19Id.BandwidthMainnet, + name: 'Tron Bandwidth', + symbol: 'BANDWIDTH', + decimals: 0, + }, + [KnownCaip19Id.BandwidthNile]: { + id: KnownCaip19Id.BandwidthNile, + name: 'Tron Bandwidth', + symbol: 'BANDWIDTH', + decimals: 0, + }, + [KnownCaip19Id.BandwidthShasta]: { + id: KnownCaip19Id.BandwidthShasta, + name: 'Tron Bandwidth', + symbol: 'BANDWIDTH', + decimals: 0, + }, + [KnownCaip19Id.BandwidthLocalnet]: { + id: KnownCaip19Id.BandwidthLocalnet, + name: 'Tron Bandwidth', + symbol: 'BANDWIDTH', + decimals: 0, + }, } as const; export const Networks = { @@ -47,23 +112,31 @@ export const Networks = { cluster: 'mainnet', name: 'Tron Mainnet', nativeToken: TokenMetadata[KnownCaip19Id.TrxMainnet], + energy: TokenMetadata[KnownCaip19Id.EnergyMainnet], + bandwidth: TokenMetadata[KnownCaip19Id.BandwidthMainnet], }, [Network.Nile]: { caip2Id: Network.Nile, cluster: 'devnet', name: 'Tron Nile', nativeToken: TokenMetadata[KnownCaip19Id.TrxNile], + energy: TokenMetadata[KnownCaip19Id.EnergyNile], + bandwidth: TokenMetadata[KnownCaip19Id.BandwidthNile], }, [Network.Shasta]: { caip2Id: Network.Shasta, cluster: 'testnet', name: 'Tron Shasta', nativeToken: TokenMetadata[KnownCaip19Id.TrxShasta], + energy: TokenMetadata[KnownCaip19Id.EnergyShasta], + bandwidth: TokenMetadata[KnownCaip19Id.BandwidthShasta], }, [Network.Localnet]: { caip2Id: Network.Localnet, cluster: 'local', name: 'Tron Localnet', nativeToken: TokenMetadata[KnownCaip19Id.TrxLocalnet], + energy: TokenMetadata[KnownCaip19Id.EnergyLocalnet], + bandwidth: TokenMetadata[KnownCaip19Id.BandwidthLocalnet], }, } as const; diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts new file mode 100644 index 00000000..4e19e264 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts @@ -0,0 +1,296 @@ +import { TransactionMapper } from './TransactionsMapper'; +import type { TronKeyringAccount } from '../../entities'; +import { Network } from '../../constants'; +import type { TransactionInfo, ContractTransactionInfo } from '../../clients/trongrid/types'; + +// Import simplified mock data (each file now contains only one transaction) +import nativeTransferMock from './mocks/native-transfer.json'; +import trc10TransferMock from './mocks/trc10-transfer.json'; +import trc20TransferMock from './mocks/trc20-transfer.json'; +import contractInfoMock from './mocks/contract-info.json'; + +describe('TransactionMapper', () => { + const mockAccount: TronKeyringAccount = { + id: 'test-account-id', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', // From native and TRC20 transfers + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:mainnet'], + entropySource: 'test-entropy', + derivationPath: 'm/0/0', + index: 0, + }; + + describe('mapTransaction', () => { + describe('TransferContract (Native TRX transfers)', () => { + it('should map a native TRX send transaction correctly', () => { + const rawTransaction = nativeTransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + }); + + console.log('Native TRX Transaction Result:', JSON.stringify(result, null, 2)); + + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + expect(result!.type).toBe('send'); + expect(result!.id).toBe('8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b'); + expect(result!.from).toHaveLength(1); + expect(result!.from[0]?.address).toBe('TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'); + expect((result!.from[0]?.asset as any)?.amount).toBe('0.01'); + expect((result!.from[0]?.asset as any)?.unit).toBe('TRX'); + expect((result!.from[0]?.asset as any)?.type).toBe('tron:728126428/slip44:195'); + expect(result!.to).toHaveLength(1); + expect(result!.to[0]?.address).toBe('TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB'); + expect(result!.chain).toBe('tron:728126428'); + expect(result!.status).toBe('confirmed'); + expect(result!.fees).toHaveLength(1); + expect((result!.fees[0]?.asset as any)?.amount).toBe('532000'); // Actual fee from mock + expect((result!.fees[0]?.asset as any)?.unit).toBe('TRX'); + }); + }); + + describe('TransferAssetContract (TRC10 transfers)', () => { + it('should map a TRC10 send transaction correctly', () => { + // Use the actual address from the TRC10 transaction mock instead of converting + const trc10Account: TronKeyringAccount = { + ...mockAccount, + id: 'test-trc10-account', + address: 'TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw', // Actual address from TRC10 mock + }; + + const rawTransaction = trc10TransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: trc10Account, + trongridTransaction: rawTransaction, + }); + + console.log('TRC10 Transaction Result:', JSON.stringify(result, null, 2)); + + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + expect(result!.type).toBe('send'); + expect(result!.id).toBe('d8fc96d5b81fe600e055741e27135e22d5ae42584c9056758f797b1a20328818'); + expect(result!.from).toHaveLength(1); + expect(result!.from[0]?.address).toBe('TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw'); + expect((result!.from[0]?.asset as any)?.amount).toBe('0.494'); + expect((result!.from[0]?.asset as any)?.unit).toBe('UNKNOWN'); + expect((result!.from[0]?.asset as any)?.type).toBe('tron:728126428/trc10:1002000'); + expect(result!.to).toHaveLength(1); + expect(result!.chain).toBe('tron:728126428'); + expect(result!.status).toBe('confirmed'); + expect(result!.fees).toHaveLength(1); + expect((result!.fees[0]?.asset as any)?.amount).toBe('562000'); // Actual TRC10 fee + expect((result!.fees[0]?.asset as any)?.unit).toBe('TRX'); + }); + }); + + describe('TriggerSmartContract (TRC20 transfers)', () => { + it('should map a TRC20 send transaction with assistance data correctly', () => { + const rawTransaction = trc20TransferMock as TransactionInfo; + const trc20AssistanceData = contractInfoMock.data[0] as ContractTransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + trc20AssistanceData: trc20AssistanceData, + }); + + console.log('TRC20 Transaction Result:', JSON.stringify(result, null, 2)); + + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + expect(result!.type).toBe('send'); + expect(result!.id).toBe('35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa'); + expect(result!.from).toHaveLength(1); + expect(result!.from[0]?.address).toBe('TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'); + expect((result!.from[0]?.asset as any)?.amount).toBe('0.01'); + expect((result!.from[0]?.asset as any)?.unit).toBe('USDT'); + expect((result!.from[0]?.asset as any)?.type).toBe('tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'); + expect(result!.to).toHaveLength(1); + expect(result!.to[0]?.address).toBe('TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB'); + expect(result!.chain).toBe('tron:728126428'); + expect(result!.status).toBe('confirmed'); + // Comprehensive fee structure: TRX fee + Bandwidth + Energy + expect(result!.fees).toHaveLength(3); + expect((result!.fees[0]?.asset as any)?.amount).toBe('25975600'); // Actual TRC20 TRX fee + expect((result!.fees[0]?.asset as any)?.unit).toBe('TRX'); + expect((result!.fees[1]?.asset as any)?.unit).toBe('BANDWIDTH'); + expect((result!.fees[1]?.asset as any)?.amount).toBe('345'); + expect((result!.fees[2]?.asset as any)?.unit).toBe('ENERGY'); + expect((result!.fees[2]?.asset as any)?.amount).toBe('130285'); // Actual energy usage from console + }); + + it('should return null for TriggerSmartContract without assistance data', () => { + const rawTransaction = trc20TransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + // No trc20AssistanceData provided + }); + + console.log('TriggerSmartContract without assistance data Result:', result); + expect(result).toBeNull(); + }); + }); + + describe('Fee calculation', () => { + it('should calculate TRX fees for native transfers', () => { + const rawTransaction = nativeTransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + }); + + console.log('Native TRX fees:', JSON.stringify(result?.fees, null, 2)); + expect(result).not.toBeNull(); + expect(result?.fees).toBeDefined(); + expect(result?.fees).toHaveLength(1); + expect((result?.fees[0]?.asset as any)?.amount).toBe('532000'); // Actual net_fee from mock + expect((result?.fees[0]?.asset as any)?.unit).toBe('TRX'); + }); + + it('should calculate comprehensive fees for TRC20 transfers', () => { + const rawTransaction = trc20TransferMock as TransactionInfo; + const trc20AssistanceData = contractInfoMock.data[0] as ContractTransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + trc20AssistanceData: trc20AssistanceData, + }); + + console.log('TRC20 comprehensive fees:', JSON.stringify(result?.fees, null, 2)); + expect(result).not.toBeNull(); + expect(result?.fees).toBeDefined(); + expect(result?.fees).toHaveLength(3); + + // TRX fee + expect((result?.fees[0]?.asset as any)?.amount).toBe('25975600'); // Actual TRC20 fee + expect((result?.fees[0]?.asset as any)?.unit).toBe('TRX'); + + // Bandwidth fee + expect((result?.fees[1]?.asset as any)?.amount).toBe('345'); + expect((result?.fees[1]?.asset as any)?.unit).toBe('BANDWIDTH'); + + // Energy fee + expect((result?.fees[2]?.asset as any)?.amount).toBe('130285'); // Actual energy from console + }); + }); + }); + + describe('mapTransactions', () => { + it('should map multiple different transaction types correctly', () => { + const rawTransactions = [ + nativeTransferMock, + trc10TransferMock, + trc20TransferMock, + ] as TransactionInfo[]; + const trc20AssistanceData = contractInfoMock.data as ContractTransactionInfo[]; + + const result = TransactionMapper.mapTransactions({ + scope: Network.Mainnet, + account: mockAccount, + rawTransactions: rawTransactions, + trc20Transactions: trc20AssistanceData, + }); + + console.log('Mapped multiple transaction types count:', result.filter(tx => tx !== null).length); + console.log('Sample mapped transactions:', JSON.stringify(result.filter(tx => tx !== null), null, 2)); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + // All 3 transactions map successfully (native TRX + TRC10 + TRC20) + expect(result.filter(tx => tx !== null)).toHaveLength(3); + }); + + it('should handle empty input arrays', () => { + const result = TransactionMapper.mapTransactions({ + scope: Network.Mainnet, + account: mockAccount, + rawTransactions: [], + trc20Transactions: [], + }); + + console.log('Empty input result:', result); + expect(result).toEqual([]); + }); + }); + + describe('Edge cases', () => { + it('should handle transaction with missing contract data', () => { + const malformedTransaction = { + txID: 'test-tx-id', + raw_data: { + contract: undefined, + }, + } as any; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: malformedTransaction, + }); + + console.log('Malformed transaction result:', result); + expect(result).toBeNull(); + }); + + it('should return null for unsupported contract types', () => { + const mockRawData = nativeTransferMock?.raw_data; + const rawTransaction = { + ...nativeTransferMock, + raw_data: { + ...mockRawData, + contract: [ + { + type: 'UnsupportedContract', + parameter: {}, + }, + ], + }, + } as any; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + }); + + console.log('Unsupported contract type result:', result); + expect(result).toBeNull(); + }); + }); + + describe('Network-specific behavior', () => { + it('should work with different networks (Shasta)', () => { + const rawTransaction = nativeTransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Shasta, // Use Shasta instead of Mainnet + account: mockAccount, + trongridTransaction: rawTransaction, + }); + + console.log('Shasta network transaction result:', JSON.stringify(result, null, 2)); + + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + expect(result!.chain).toBe('tron:2494104990'); // Shasta chain ID + expect((result!.from[0]?.asset as any)?.type).toBe('tron:2494104990/slip44:195'); + expect((result!.to[0]?.asset as any)?.type).toBe('tron:2494104990/slip44:195'); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts index a517d6b0..d7ea816e 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts @@ -7,10 +7,78 @@ import type { TransferContractInfo, TransferAssetContractInfo, } from '../../clients/trongrid/types'; -import type { Network } from '../../constants'; +import { Network, Networks } from '../../constants'; import type { TronKeyringAccount } from '../../entities'; export class TransactionMapper { + /** + * Calculate fees for Tron transactions including Energy and Bandwidth as separate assets. + * @param transactionInfo The raw transaction info. + * @param network The network configuration. + * @returns Array of fee objects. + */ + static #calculateTronFees( + network: Network, + transactionInfo: TransactionInfo, + ): Transaction['fees'] { + const fees: Transaction['fees'] = []; + + const tronAsset = Networks[network].nativeToken + const bandwidthAsset = Networks[network].bandwidth + const energyAsset = Networks[network].energy + + // Base TRX fee calculation + const transactionFee = transactionInfo.ret.reduce( + (total, result) => total + (result.fee || 0), + 0, + ); + const netFeeTotal = + transactionFee + + Number(transactionInfo.net_fee) + + Number(transactionInfo.energy_fee); + + if (netFeeTotal > 0) { + fees.push({ + type: 'base', + asset: { + type: tronAsset.id, + unit: tronAsset.symbol, + amount: netFeeTotal.toString(), + fungible: true, + }, + }); + } + + // Bandwidth usage as separate asset + const netUsage = Number(transactionInfo.net_usage); + if (netUsage > 0) { + fees.push({ + type: 'base', + asset: { + type: bandwidthAsset.id, + unit: bandwidthAsset.symbol, + amount: netUsage.toString(), + fungible: true, + }, + }); + } + + // Energy usage as separate asset + const energyUsage = Number(transactionInfo.energy_usage_total); + if (energyUsage > 0) { + fees.push({ + type: 'base', + asset: { + type: energyAsset.id, + unit: energyAsset.symbol, + amount: energyUsage.toString(), + fungible: true, + }, + }); + } + + return fees; + } /** * Maps a TransferContract (native TRX transfer) transaction. * @@ -38,9 +106,9 @@ export class TransactionMapper { // Convert from sun to TRX (divide by 10^6) const amountInSun = contractValue.amount; const amountInTrx = (amountInSun / 1_000_000).toString(); - const fee = trongridTransaction.ret[0]?.fee?.toString() ?? '0'; - // Convert fee from sun to TRX as well - const feeInTrx = (parseInt(fee, 10) / 1_000_000).toString(); + + // Calculate comprehensive fees including Energy and Bandwidth + const fees = TransactionMapper.#calculateTronFees(scope, trongridTransaction); let type: 'send' | 'receive' | 'unknown'; @@ -52,6 +120,8 @@ export class TransactionMapper { type = 'unknown'; } + const tronAsset = Networks[scope].nativeToken + return { type, id: trongridTransaction.txID, @@ -59,8 +129,8 @@ export class TransactionMapper { { address: from as any, asset: { - unit: 'TRX', - type: `${scope}/slip44:195`, + unit: tronAsset.symbol, + type: tronAsset.id, amount: amountInTrx, fungible: true, }, @@ -70,8 +140,8 @@ export class TransactionMapper { { address: to, asset: { - unit: 'TRX', - type: `${scope}/slip44:195`, + unit: tronAsset.symbol, + type: tronAsset.id, amount: amountInTrx, fungible: true, }, @@ -87,17 +157,7 @@ export class TransactionMapper { status: 'confirmed', account: account.id, timestamp: trongridTransaction.block_timestamp, - fees: [ - { - type: 'base', - asset: { - unit: 'TRX', - type: `${scope}/slip44:195`, - amount: feeInTrx, - fungible: true, - }, - }, - ], + fees, }; } @@ -129,9 +189,9 @@ export class TransactionMapper { const amountInSmallestUnit = contractValue.amount; const amountInReadableUnit = (amountInSmallestUnit / 1_000_000).toString(); const assetName = contractValue.asset_name; - const fee = trongridTransaction.ret[0]?.fee?.toString() ?? '0'; - // Convert fee from sun to TRX - const feeInTrx = (parseInt(fee, 10) / 1_000_000).toString(); + + // Calculate comprehensive fees including Energy and Bandwidth + const fees = TransactionMapper.#calculateTronFees(scope, trongridTransaction); let type: 'send' | 'receive' | 'unknown'; @@ -150,8 +210,8 @@ export class TransactionMapper { { address: from as any, asset: { - unit: assetName, // Using the actual TRC10 asset name - type: `${scope}/slip44:195`, + unit: 'UNKNOWN', + type: `${scope}/trc10:${assetName}`, amount: amountInReadableUnit, fungible: true, }, @@ -161,8 +221,8 @@ export class TransactionMapper { { address: to, asset: { - unit: assetName, // Using the actual TRC10 asset name - type: `${scope}/slip44:195`, + unit: 'UNKNOWN', + type: `${scope}/trc10:${assetName}`, amount: amountInReadableUnit, fungible: true, }, @@ -178,17 +238,7 @@ export class TransactionMapper { status: 'confirmed', account: account.id, timestamp: trongridTransaction.block_timestamp, - fees: [ - { - type: 'base', - asset: { - unit: 'TRX', - type: `${scope}/slip44:195`, - amount: feeInTrx, - fungible: true, - }, - }, - ], + fees, }; } @@ -225,7 +275,7 @@ export class TransactionMapper { // Convert from smallest unit to human-readable amount using token decimals const valueInSmallestUnit = trc20AssistanceData.value; - const { decimals } = trc20AssistanceData.token_info; + const { decimals, address, symbol } = trc20AssistanceData.token_info; const divisor = Math.pow(10, decimals); const valueInReadableUnit = ( parseFloat(valueInSmallestUnit) / divisor @@ -240,12 +290,8 @@ export class TransactionMapper { type = 'unknown'; } - // Calculate total fees from raw transaction data - const totalFee = trongridTransaction.ret[0]?.fee ?? 0; - const netFee = trongridTransaction.net_fee ?? 0; - const energyFee = trongridTransaction.energy_fee ?? 0; - const combinedFee = totalFee + netFee + energyFee; - const feeInTrx = (combinedFee / 1_000_000).toString(); + // Calculate comprehensive fees including Energy and Bandwidth from raw transaction data + const fees = TransactionMapper.#calculateTronFees(scope, trongridTransaction); return { type, @@ -254,8 +300,8 @@ export class TransactionMapper { { address: from as any, asset: { - unit: trc20AssistanceData.token_info.symbol, - type: `${scope}/slip44:195`, + unit: symbol, + type: `${scope}/trc20:${address}`, amount: valueInReadableUnit, fungible: true, }, @@ -265,8 +311,8 @@ export class TransactionMapper { { address: to, asset: { - unit: trc20AssistanceData.token_info.symbol, - type: `${scope}/slip44:195`, + unit: symbol, + type: `${scope}/trc20:${address}`, amount: valueInReadableUnit, fungible: true, }, @@ -282,17 +328,7 @@ export class TransactionMapper { status: 'confirmed', account: account.id, timestamp: trc20AssistanceData.block_timestamp, - fees: [ - { - type: 'base', - asset: { - unit: 'TRX', - type: `${scope}/slip44:195`, - amount: feeInTrx, - fungible: true, - }, - }, - ], + fees, }; } diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts new file mode 100644 index 00000000..1b07a0e7 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts @@ -0,0 +1,426 @@ +import { TransactionsService } from './TransactionsService'; +import type { TronKeyringAccount } from '../../entities'; +import { Network, KnownCaip19Id } from '../../constants'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { TransactionsRepository } from './TransactionsRepository'; +import type { ILogger } from '../../utils/logger'; +import type { TransactionInfo, ContractTransactionInfo } from '../../clients/trongrid/types'; +import type { Transaction } from '@metamask/keyring-api'; + +// Import simplified mock data (each file now contains only one transaction) +import nativeTransferMock from './mocks/native-transfer.json'; +import trc10TransferMock from './mocks/trc10-transfer.json'; +import trc20TransferMock from './mocks/trc20-transfer.json'; +import contractInfoMock from './mocks/contract-info.json'; + +describe('TransactionsService', () => { + let transactionsService: TransactionsService; + let mockLogger: jest.Mocked; + let mockTransactionsRepository: jest.Mocked; + let mockTrongridApiClient: jest.Mocked; + + const mockAccount: TronKeyringAccount = { + id: 'test-account-id', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:mainnet'], + entropySource: 'test-entropy', + derivationPath: 'm/0/0', + index: 0, + }; + + const mockAccount2: TronKeyringAccount = { + id: 'test-account-id-2', + address: 'TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:mainnet'], + entropySource: 'test-entropy', + derivationPath: 'm/0/1', + index: 1, + }; + + beforeEach(() => { + // Mock the global snap object + const snap = { + request: jest.fn(), + }; + (globalThis as any).snap = snap; + + // Create mocks + mockLogger = { + log: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + // Create mock repository + mockTransactionsRepository = { + getAll: jest.fn(), + findByAccountId: jest.fn(), + save: jest.fn(), + saveMany: jest.fn(), + } as unknown as jest.Mocked; + + // Create mock API client + mockTrongridApiClient = { + getAccountInfoByAddress: jest.fn(), + getTransactionInfoByAddress: jest.fn(), + getContractTransactionInfoByAddress: jest.fn(), + } as unknown as jest.Mocked; + + // Create service instance + transactionsService = new TransactionsService({ + logger: mockLogger, + transactionsRepository: mockTransactionsRepository, + trongridApiClient: mockTrongridApiClient, + }); + }); + + describe('fetchTransactionsForAccount', () => { + it('should fetch and map transactions for an account using native transfers mock data', async () => { + // Setup mock responses with simplified single-transaction structure + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue( + [nativeTransferMock] as TransactionInfo[] + ); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + contractInfoMock.data as ContractTransactionInfo[] + ); + + const result = await transactionsService.fetchTransactionsForAccount( + Network.Mainnet, + mockAccount + ); + + console.log('Fetched transactions count:', result.length); + console.log('Sample fetched transactions:', JSON.stringify(result.slice(0, 2), null, 2)); + + // Verify API calls were made + expect(mockTrongridApiClient.getTransactionInfoByAddress).toHaveBeenCalledWith( + Network.Mainnet, + mockAccount.address + ); + expect(mockTrongridApiClient.getContractTransactionInfoByAddress).toHaveBeenCalledWith( + Network.Mainnet, + mockAccount.address + ); + + // Verify logger calls + expect(mockLogger.info).toHaveBeenCalledWith( + '[🧾 TransactionsService]', + expect.stringContaining('Fetching transactions for account') + ); + + expect(true).toBe(true); + }); + + it('should fetch and map transactions for an account using TRC10 transfers mock data', async () => { + // Setup mock responses with simplified single-transaction structure + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue( + [trc10TransferMock] as TransactionInfo[] + ); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue([]); + + const result = await transactionsService.fetchTransactionsForAccount( + Network.Mainnet, + mockAccount2 + ); + + console.log('Fetched TRC10 transactions count:', result.length); + console.log('Sample fetched TRC10 transactions:', JSON.stringify(result.slice(0, 2), null, 2)); + + // Verify API calls were made + expect(mockTrongridApiClient.getTransactionInfoByAddress).toHaveBeenCalledWith( + Network.Mainnet, + mockAccount2.address + ); + expect(mockTrongridApiClient.getContractTransactionInfoByAddress).toHaveBeenCalledWith( + Network.Mainnet, + mockAccount2.address + ); + + expect(true).toBe(true); + }); + + it('should handle network parameter correctly for different networks', async () => { + // Setup mock responses + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue([]); + + await transactionsService.fetchTransactionsForAccount( + Network.Shasta, + mockAccount + ); + + // Verify API calls were made with correct network + expect(mockTrongridApiClient.getTransactionInfoByAddress).toHaveBeenCalledWith( + Network.Shasta, + mockAccount.address + ); + expect(mockTrongridApiClient.getContractTransactionInfoByAddress).toHaveBeenCalledWith( + Network.Shasta, + mockAccount.address + ); + + expect(true).toBe(true); + }); + + it('should handle empty responses from API', async () => { + // Setup empty mock responses + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue([]); + + const result = await transactionsService.fetchTransactionsForAccount( + Network.Mainnet, + mockAccount + ); + + console.log('Empty API response result:', result); + expect(result).toEqual([]); + expect(true).toBe(true); + }); + + it('should handle API errors gracefully', async () => { + // Setup API to throw error + const apiError = new Error('API request failed'); + mockTrongridApiClient.getTransactionInfoByAddress.mockRejectedValue(apiError); + + await expect( + transactionsService.fetchTransactionsForAccount(Network.Mainnet, mockAccount) + ).rejects.toThrow('API request failed'); + + expect(true).toBe(true); + }); + }); + + describe('findByAccounts', () => { + it('should find transactions for multiple accounts', async () => { + const mockTransactions1: Transaction[] = [ + { + id: 'tx1', + type: 'send', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Date.now(), + from: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + events: [], + fees: [], + }, + ]; + + const mockTransactions2: Transaction[] = [ + { + id: 'tx2', + type: 'receive', + account: mockAccount2.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Date.now(), + from: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '50', unit: 'TRX', fungible: true } }], + to: [{ address: mockAccount2.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '50', unit: 'TRX', fungible: true } }], + events: [], + fees: [], + }, + ]; + + mockTransactionsRepository.findByAccountId + .mockResolvedValueOnce(mockTransactions1) + .mockResolvedValueOnce(mockTransactions2); + + const result = await transactionsService.findByAccounts([mockAccount, mockAccount2]); + + console.log('Found transactions for multiple accounts:', JSON.stringify(result, null, 2)); + + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledTimes(2); + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledWith(mockAccount.id); + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledWith(mockAccount2.id); + expect(result).toHaveLength(2); + expect(true).toBe(true); + }); + + it('should handle empty accounts array', async () => { + const result = await transactionsService.findByAccounts([]); + + console.log('Empty accounts result:', result); + expect(result).toEqual([]); + expect(mockTransactionsRepository.findByAccountId).not.toHaveBeenCalled(); + expect(true).toBe(true); + }); + }); + + describe('save', () => { + it('should save a single transaction', async () => { + const mockTransaction: Transaction = { + id: 'tx-save-test', + type: 'send', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Date.now(), + from: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + events: [], + fees: [], + }; + + await transactionsService.save(mockTransaction); + + console.log('Saved single transaction:', mockTransaction.id); + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([mockTransaction]); + expect(true).toBe(true); + }); + }); + + describe('saveMany', () => { + it('should save multiple transactions and emit keyring event', async () => { + const mockTransactions: Transaction[] = [ + { + id: 'tx-bulk-1', + type: 'send', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Date.now(), + from: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + events: [], + fees: [], + }, + { + id: 'tx-bulk-2', + type: 'receive', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Date.now(), + from: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '50', unit: 'TRX', fungible: true } }], + to: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '50', unit: 'TRX', fungible: true } }], + events: [], + fees: [], + }, + ]; + + await transactionsService.saveMany(mockTransactions); + + console.log('Saved multiple transactions count:', mockTransactions.length); + console.log('Saved transactions IDs:', mockTransactions.map(tx => tx.id)); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith(mockTransactions); + expect(true).toBe(true); + }); + + it('should handle empty transactions array', async () => { + await transactionsService.saveMany([]); + + console.log('Saved empty transactions array'); + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([]); + expect(true).toBe(true); + }); + + it('should group transactions by account ID correctly', async () => { + const mockTransactions: Transaction[] = [ + { + id: 'tx-account1-1', + type: 'send', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Date.now(), + from: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + events: [], + fees: [], + }, + { + id: 'tx-account1-2', + type: 'receive', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Date.now(), + from: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '25', unit: 'TRX', fungible: true } }], + to: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '25', unit: 'TRX', fungible: true } }], + events: [], + fees: [], + }, + { + id: 'tx-account2-1', + type: 'send', + account: mockAccount2.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Date.now(), + from: [{ address: mockAccount2.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '75', unit: 'TRX', fungible: true } }], + to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '75', unit: 'TRX', fungible: true } }], + events: [], + fees: [], + }, + ]; + + await transactionsService.saveMany(mockTransactions); + + console.log('Grouped transactions by account:'); + console.log(`Account ${mockAccount.id}: 2 transactions`); + console.log(`Account ${mockAccount2.id}: 1 transaction`); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith(mockTransactions); + expect(true).toBe(true); + }); + }); + + describe('Integration scenarios', () => { + it('should handle a complete flow: fetch, process, and save transactions', async () => { + // Setup API responses with simplified single-transaction structure + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue( + [nativeTransferMock] as TransactionInfo[] + ); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + contractInfoMock.data.slice(0, 1) as ContractTransactionInfo[] + ); + + // Fetch transactions + const fetchedTransactions = await transactionsService.fetchTransactionsForAccount( + Network.Mainnet, + mockAccount + ); + + // Save the fetched transactions + await transactionsService.saveMany(fetchedTransactions); + + console.log('Complete flow - Fetched and saved transactions:', fetchedTransactions.length); + console.log('Sample transaction IDs:', fetchedTransactions.slice(0, 2).map(tx => tx.id)); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith(fetchedTransactions); + expect(true).toBe(true); + }); + + it('should handle mixed transaction types from different mock data sources', async () => { + // Mix different types of transactions with simplified structure + const mixedRawTransactions = [ + nativeTransferMock, // Native TRX transfer + trc10TransferMock, // TRC10 transfer + trc20TransferMock, // TRC20 transfer + ] as TransactionInfo[]; + + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue(mixedRawTransactions); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue([]); + + const result = await transactionsService.fetchTransactionsForAccount( + Network.Mainnet, + mockAccount2 + ); + + console.log('Mixed transaction types result:', result.length); + console.log('Transaction types:', result.map(tx => tx.type)); + + expect(true).toBe(true); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/contract-info.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/contract-info.json new file mode 100644 index 00000000..c1694ac3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/contract-info.json @@ -0,0 +1,51 @@ +{ + "data": [ + { + "transaction_id": "35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa", + "token_info": { + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + "name": "Tether USD" + }, + "block_timestamp": 1757590707000, + "from": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "to": "TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB", + "type": "Transfer", + "value": "10000" + }, + { + "transaction_id": "beb6312754bed1de71c4087d8f0ab078be4f8b7d25b97a6fed644a1e274c51dc", + "token_info": { + "symbol": "USDDOLD", + "address": "TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn", + "decimals": 18, + "name": "Decentralized USDOLD" + }, + "block_timestamp": 1756119357000, + "from": "TMxxHG5PRVakKwNCvTWDb73gPwXvkZAhpm", + "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "type": "Transfer", + "value": "1052192663968474700" + }, + { + "transaction_id": "036fd19d19626c2be4010619a365ca8d1e15f6b615585170500c7b25f1932686", + "token_info": { + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + "name": "Tether USD" + }, + "block_timestamp": 1755862641000, + "from": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", + "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "type": "Transfer", + "value": "1067519" + } + ], + "success": true, + "meta": { + "at": 1757687703291, + "page_size": 3 + } +} \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/native-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/native-transfer.json new file mode 100644 index 00000000..f3151aa8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/native-transfer.json @@ -0,0 +1,40 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 266000 + } + ], + "signature": [ + "128b88278e273e5b1f89c6f0e1047af67406206bc5c848229d072dede4303ba90ccc25393003fc4346d7aaa8c0d44570d3f86510fa74e6435024484e5fec77de1b" + ], + "txID": "8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b", + "net_usage": 0, + "raw_data_hex": "0a02cb1d2208d3fc65a6fb0f782d40e8b2a18291335a66080112620a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412310a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff41215412efffc7686e54ab669a1cdb1e2cc17cf4b4eca9618904e7088de9d829133", + "net_fee": 266000, + "energy_usage": 0, + "blockNumber": 75418399, + "block_timestamp": 1756914747000, + "energy_fee": 0, + "energy_usage_total": 0, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 10000, + "owner_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4", + "to_address": "412efffc7686e54ab669a1cdb1e2cc17cf4b4eca96" + }, + "type_url": "type.googleapis.com/protocol.TransferContract" + }, + "type": "TransferContract" + } + ], + "ref_block_bytes": "cb1d", + "ref_block_hash": "d3fc65a6fb0f782d", + "expiration": 1756914801000, + "timestamp": 1756914741000 + }, + "internal_transactions": [] +} \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc10-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc10-transfer.json new file mode 100644 index 00000000..47939430 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc10-transfer.json @@ -0,0 +1,41 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 281000 + } + ], + "signature": [ + "1910a1c31507d603df8de792ebfe30cf29e988fdb7a964877a296d75b998c68609bb306c95f5f28a7fc6295fb39548423dc7a213ff97a1cce63022b9eed9541900" + ], + "txID": "d8fc96d5b81fe600e055741e27135e22d5ae42584c9056758f797b1a20328818", + "net_usage": 0, + "raw_data_hex": "0a0291bc22087bb3675595654f0e40d0839fed90335a75080212710a32747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e736665724173736574436f6e7472616374123b0a07313030323030301215413986cff58bc3066e62f43f2e32f603d026a437261a15416902a7371d035fed4e85fa9642e689c657634b7520b0931e70f0ae9bed9033", + "net_fee": 281000, + "energy_usage": 0, + "blockNumber": 75403713, + "block_timestamp": 1756870677000, + "energy_fee": 0, + "energy_usage_total": 0, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 494000, + "asset_name": "1002000", + "owner_address": "413986cff58bc3066e62f43f2e32f603d026a43726", + "to_address": "416902a7371d035fed4e85fa9642e689c657634b75" + }, + "type_url": "type.googleapis.com/protocol.TransferAssetContract" + }, + "type": "TransferAssetContract" + } + ], + "ref_block_bytes": "91bc", + "ref_block_hash": "7bb3675595654f0e", + "expiration": 1756870722000, + "timestamp": 1756870662000 + }, + "internal_transactions": [] +} \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc20-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc20-transfer.json new file mode 100644 index 00000000..a66e6875 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc20-transfer.json @@ -0,0 +1,41 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 12987800 + } + ], + "signature": [ + "0d1a94c67125916037fe92b7f266cd595100803f5ceb21838c43f3e97a5346ee3798ee350cca88f3e04212baf984dc638a2c965fb42e31301d3556ebf04630101c" + ], + "txID": "35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa", + "net_usage": 345, + "raw_data_hex": "0a023b08220868ca0d471db52d6f40e0f3cac493335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff4121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244a9059cbb0000000000000000000000002efffc7686e54ab669a1cdb1e2cc17cf4b4eca96000000000000000000000000000000000000000000000000000000000000271070809fc7c493339001c0f4a46b", + "net_fee": 0, + "energy_usage": 407, + "blockNumber": 75643657, + "block_timestamp": 1757590707000, + "energy_fee": 12987800, + "energy_usage_total": 130285, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000002efffc7686e54ab669a1cdb1e2cc17cf4b4eca960000000000000000000000000000000000000000000000000000000000002710", + "owner_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4", + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "3b08", + "ref_block_hash": "68ca0d471db52d6f", + "expiration": 1757590764000, + "fee_limit": 225000000, + "timestamp": 1757590704000 + }, + "internal_transactions": [] +} \ No newline at end of file From 4302dced4f1f6284ebdbe5787f7ad07507e55483 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 18 Sep 2025 10:12:32 +0100 Subject: [PATCH 025/238] chore: simplify `calculateTronFees` + feat: implement `discoverAccounts` (#26) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../tron-wallet-snap/src/handlers/keyring.ts | 43 +- .../src/services/accounts/AccountsService.ts | 68 ++- .../transactions/TransactionsMapper.test.ts | 138 ++++-- .../transactions/TransactionsMapper.ts | 95 ++-- .../transactions/TransactionsService.test.ts | 407 +++++++++++++----- 6 files changed, 548 insertions(+), 205 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 4f6b0a73..b5794ecb 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "EEnV9UPTWHRhqZhfCqWbG/YwvYtRAiJwCGe/mAXUL7I=", + "shasum": "ma/N3PNnetiVUQDihugFV1xtD3FXaO5iehab1IsmgPc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index 80a25ece..296585cc 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -31,6 +31,7 @@ import { sortBy } from 'lodash'; import type { TronKeyringAccount } from '../entities'; import { BackgroundEventMethod } from './cronjob'; import type { SnapClient } from '../clients/snap/SnapClient'; +import type { Network } from '../constants'; import type { AccountsService } from '../services/accounts/AccountsService'; import type { CreateAccountOptions } from '../services/accounts/types'; import type { AssetsService } from '../services/assets/AssetsService'; @@ -126,7 +127,6 @@ export class KeyringHandler implements Keyring { async createAccount(options?: CreateAccountOptions): Promise { const id = globalThis.crypto.randomUUID(); - try { const account = await this.#accountsService.create(id, options); @@ -136,7 +136,7 @@ export class KeyringHandler implements Keyring { */ await this.#snapClient.scheduleBackgroundEvent({ method: BackgroundEventMethod.SyncAccountTransactions, - params: { accountId: id }, + params: { accountId: account.id }, duration: 'PT1S', }); @@ -232,7 +232,44 @@ export class KeyringHandler implements Keyring { entropySource: EntropySourceId, groupIndex: number, ): Promise { - return []; + try { + const account = await this.#accountsService.deriveAccount({ + entropySource, + index: groupIndex, + }); + + const activityChecksPromises = []; + + for (const scope of scopes) { + activityChecksPromises.push( + this.#transactionsService.fetchTransactionsForAccount( + scope as Network, + account, + ), + ); + } + + const transactionsOnAllScopes = await Promise.all(activityChecksPromises); + + const hasActivity = transactionsOnAllScopes.some( + (transactions) => transactions.length > 0, + ); + + if (!hasActivity) { + return []; + } + + return [ + { + type: 'bip44', + scopes, + derivationPath: account.derivationPath, + }, + ]; + } catch (error: any) { + this.#logger.error({ error }, 'Error discovering accounts'); + throw error; + } } async getAccountBalances( diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 7f6ca455..c4139b39 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -3,6 +3,7 @@ import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { assert, integer, pattern, string } from '@metamask/superstruct'; import { hexToBytes } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import { TronWeb } from 'tronweb'; import type { SnapClient } from '../../clients/snap/SnapClient'; @@ -125,6 +126,48 @@ export class AccountsService { }; } + async deriveAccount({ + entropySource, + index, + derivationPath: customDerivationPath, + }: { + entropySource: EntropySourceId; + index?: number; + derivationPath?: `m/${string}`; + }): Promise { + const derivationPath = + customDerivationPath ?? + (index !== undefined ? this.#getDefaultDerivationPath(index) : undefined); + + if (!derivationPath) { + throw new Error('Either index or derivationPath must be provided'); + } + + const accountIndex = + index ?? this.#getIndexFromDerivationPath(derivationPath); + + const { address } = await this.deriveTronKeypair({ + entropySource, + derivationPath, + }); + + return { + id: '', + entropySource, + derivationPath, + index: accountIndex, + type: TrxAccountType.Eoa, + address, + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropySource, + derivationPath, + index: accountIndex, + }, + methods: ['signMessageV2', 'verifyMessageV2'], + }; + } + async create( id: string, options?: CreateAccountOptions, @@ -158,8 +201,9 @@ export class AccountsService { return asStrictKeyringAccount(sameAccount); } - const { address: accountAddress } = await this.deriveTronKeypair({ + const derivedAccount = await this.deriveAccount({ entropySource, + index, derivationPath, }); @@ -171,24 +215,16 @@ export class AccountsService { } = options ?? {}; const tronKeyringAccount: TronKeyringAccount = { + ...derivedAccount, id, - entropySource, - derivationPath, - index, - type: TrxAccountType.Eoa, - address: accountAddress, - scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], options: { - ...remainingOptions, - /** - * Make sure to save the `entropySource`, `derivationPath` and `index` - * in the keyring account options.. - */ - entropySource, - derivationPath, - index, + ...derivedAccount.options, + ...(Object.fromEntries( + Object.entries(remainingOptions).filter( + ([, value]) => value !== undefined, + ), + ) as Record), }, - methods: ['signMessageV2', 'verifyMessageV2'], }; await this.#accountsRepository.create(tronKeyringAccount); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts index 4e19e264..d1260379 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts @@ -1,13 +1,16 @@ import { TransactionMapper } from './TransactionsMapper'; -import type { TronKeyringAccount } from '../../entities'; +import type { + TransactionInfo, + ContractTransactionInfo, +} from '../../clients/trongrid/types'; import { Network } from '../../constants'; -import type { TransactionInfo, ContractTransactionInfo } from '../../clients/trongrid/types'; +import type { TronKeyringAccount } from '../../entities'; // Import simplified mock data (each file now contains only one transaction) +import contractInfoMock from './mocks/contract-info.json'; import nativeTransferMock from './mocks/native-transfer.json'; import trc10TransferMock from './mocks/trc10-transfer.json'; import trc20TransferMock from './mocks/trc20-transfer.json'; -import contractInfoMock from './mocks/contract-info.json'; describe('TransactionMapper', () => { const mockAccount: TronKeyringAccount = { @@ -33,19 +36,30 @@ describe('TransactionMapper', () => { trongridTransaction: rawTransaction, }); - console.log('Native TRX Transaction Result:', JSON.stringify(result, null, 2)); - + console.log( + 'Native TRX Transaction Result:', + JSON.stringify(result, null, 2), + ); + expect(result).toBeDefined(); expect(result).not.toBeNull(); expect(result!.type).toBe('send'); - expect(result!.id).toBe('8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b'); + expect(result!.id).toBe( + '8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b', + ); expect(result!.from).toHaveLength(1); - expect(result!.from[0]?.address).toBe('TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'); + expect(result!.from[0]?.address).toBe( + 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + ); expect((result!.from[0]?.asset as any)?.amount).toBe('0.01'); expect((result!.from[0]?.asset as any)?.unit).toBe('TRX'); - expect((result!.from[0]?.asset as any)?.type).toBe('tron:728126428/slip44:195'); + expect((result!.from[0]?.asset as any)?.type).toBe( + 'tron:728126428/slip44:195', + ); expect(result!.to).toHaveLength(1); - expect(result!.to[0]?.address).toBe('TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB'); + expect(result!.to[0]?.address).toBe( + 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + ); expect(result!.chain).toBe('tron:728126428'); expect(result!.status).toBe('confirmed'); expect(result!.fees).toHaveLength(1); @@ -71,17 +85,26 @@ describe('TransactionMapper', () => { trongridTransaction: rawTransaction, }); - console.log('TRC10 Transaction Result:', JSON.stringify(result, null, 2)); - + console.log( + 'TRC10 Transaction Result:', + JSON.stringify(result, null, 2), + ); + expect(result).toBeDefined(); expect(result).not.toBeNull(); expect(result!.type).toBe('send'); - expect(result!.id).toBe('d8fc96d5b81fe600e055741e27135e22d5ae42584c9056758f797b1a20328818'); + expect(result!.id).toBe( + 'd8fc96d5b81fe600e055741e27135e22d5ae42584c9056758f797b1a20328818', + ); expect(result!.from).toHaveLength(1); - expect(result!.from[0]?.address).toBe('TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw'); + expect(result!.from[0]?.address).toBe( + 'TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw', + ); expect((result!.from[0]?.asset as any)?.amount).toBe('0.494'); expect((result!.from[0]?.asset as any)?.unit).toBe('UNKNOWN'); - expect((result!.from[0]?.asset as any)?.type).toBe('tron:728126428/trc10:1002000'); + expect((result!.from[0]?.asset as any)?.type).toBe( + 'tron:728126428/trc10:1002000', + ); expect(result!.to).toHaveLength(1); expect(result!.chain).toBe('tron:728126428'); expect(result!.status).toBe('confirmed'); @@ -94,28 +117,40 @@ describe('TransactionMapper', () => { describe('TriggerSmartContract (TRC20 transfers)', () => { it('should map a TRC20 send transaction with assistance data correctly', () => { const rawTransaction = trc20TransferMock as TransactionInfo; - const trc20AssistanceData = contractInfoMock.data[0] as ContractTransactionInfo; + const trc20AssistanceData = contractInfoMock + .data[0] as ContractTransactionInfo; const result = TransactionMapper.mapTransaction({ scope: Network.Mainnet, account: mockAccount, trongridTransaction: rawTransaction, - trc20AssistanceData: trc20AssistanceData, + trc20AssistanceData, }); - console.log('TRC20 Transaction Result:', JSON.stringify(result, null, 2)); - + console.log( + 'TRC20 Transaction Result:', + JSON.stringify(result, null, 2), + ); + expect(result).toBeDefined(); expect(result).not.toBeNull(); expect(result!.type).toBe('send'); - expect(result!.id).toBe('35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa'); + expect(result!.id).toBe( + '35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa', + ); expect(result!.from).toHaveLength(1); - expect(result!.from[0]?.address).toBe('TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'); + expect(result!.from[0]?.address).toBe( + 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + ); expect((result!.from[0]?.asset as any)?.amount).toBe('0.01'); expect((result!.from[0]?.asset as any)?.unit).toBe('USDT'); - expect((result!.from[0]?.asset as any)?.type).toBe('tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'); + expect((result!.from[0]?.asset as any)?.type).toBe( + 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ); expect(result!.to).toHaveLength(1); - expect(result!.to[0]?.address).toBe('TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB'); + expect(result!.to[0]?.address).toBe( + 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + ); expect(result!.chain).toBe('tron:728126428'); expect(result!.status).toBe('confirmed'); // Comprehensive fee structure: TRX fee + Bandwidth + Energy @@ -138,7 +173,10 @@ describe('TransactionMapper', () => { // No trc20AssistanceData provided }); - console.log('TriggerSmartContract without assistance data Result:', result); + console.log( + 'TriggerSmartContract without assistance data Result:', + result, + ); expect(result).toBeNull(); }); }); @@ -163,28 +201,32 @@ describe('TransactionMapper', () => { it('should calculate comprehensive fees for TRC20 transfers', () => { const rawTransaction = trc20TransferMock as TransactionInfo; - const trc20AssistanceData = contractInfoMock.data[0] as ContractTransactionInfo; + const trc20AssistanceData = contractInfoMock + .data[0] as ContractTransactionInfo; const result = TransactionMapper.mapTransaction({ scope: Network.Mainnet, account: mockAccount, trongridTransaction: rawTransaction, - trc20AssistanceData: trc20AssistanceData, + trc20AssistanceData, }); - console.log('TRC20 comprehensive fees:', JSON.stringify(result?.fees, null, 2)); + console.log( + 'TRC20 comprehensive fees:', + JSON.stringify(result?.fees, null, 2), + ); expect(result).not.toBeNull(); expect(result?.fees).toBeDefined(); expect(result?.fees).toHaveLength(3); - + // TRX fee expect((result?.fees[0]?.asset as any)?.amount).toBe('25975600'); // Actual TRC20 fee expect((result?.fees[0]?.asset as any)?.unit).toBe('TRX'); - + // Bandwidth fee expect((result?.fees[1]?.asset as any)?.amount).toBe('345'); expect((result?.fees[1]?.asset as any)?.unit).toBe('BANDWIDTH'); - + // Energy fee expect((result?.fees[2]?.asset as any)?.amount).toBe('130285'); // Actual energy from console }); @@ -198,22 +240,33 @@ describe('TransactionMapper', () => { trc10TransferMock, trc20TransferMock, ] as TransactionInfo[]; - const trc20AssistanceData = contractInfoMock.data as ContractTransactionInfo[]; + const trc20AssistanceData = + contractInfoMock.data as ContractTransactionInfo[]; const result = TransactionMapper.mapTransactions({ scope: Network.Mainnet, account: mockAccount, - rawTransactions: rawTransactions, + rawTransactions, trc20Transactions: trc20AssistanceData, }); - console.log('Mapped multiple transaction types count:', result.filter(tx => tx !== null).length); - console.log('Sample mapped transactions:', JSON.stringify(result.filter(tx => tx !== null), null, 2)); - + console.log( + 'Mapped multiple transaction types count:', + result.filter((tx) => tx !== null).length, + ); + console.log( + 'Sample mapped transactions:', + JSON.stringify( + result.filter((tx) => tx !== null), + null, + 2, + ), + ); + expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); // All 3 transactions map successfully (native TRX + TRC10 + TRC20) - expect(result.filter(tx => tx !== null)).toHaveLength(3); + expect(result.filter((tx) => tx !== null)).toHaveLength(3); }); it('should handle empty input arrays', () => { @@ -284,13 +337,20 @@ describe('TransactionMapper', () => { trongridTransaction: rawTransaction, }); - console.log('Shasta network transaction result:', JSON.stringify(result, null, 2)); - + console.log( + 'Shasta network transaction result:', + JSON.stringify(result, null, 2), + ); + expect(result).toBeDefined(); expect(result).not.toBeNull(); expect(result!.chain).toBe('tron:2494104990'); // Shasta chain ID - expect((result!.from[0]?.asset as any)?.type).toBe('tron:2494104990/slip44:195'); - expect((result!.to[0]?.asset as any)?.type).toBe('tron:2494104990/slip44:195'); + expect((result!.from[0]?.asset as any)?.type).toBe( + 'tron:2494104990/slip44:195', + ); + expect((result!.to[0]?.asset as any)?.type).toBe( + 'tron:2494104990/slip44:195', + ); }); }); }); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts index d7ea816e..90533b5a 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts @@ -1,4 +1,4 @@ -import type { Transaction } from '@metamask/keyring-api'; +import type { CaipAssetType, Transaction } from '@metamask/keyring-api'; import { TronWeb } from 'tronweb'; import type { @@ -7,12 +7,14 @@ import type { TransferContractInfo, TransferAssetContractInfo, } from '../../clients/trongrid/types'; -import { Network, Networks } from '../../constants'; +import type { Network } from '../../constants'; +import { Networks } from '../../constants'; import type { TronKeyringAccount } from '../../entities'; export class TransactionMapper { /** * Calculate fees for Tron transactions including Energy and Bandwidth as separate assets. + * * @param transactionInfo The raw transaction info. * @param network The network configuration. * @returns Array of fee objects. @@ -23,62 +25,46 @@ export class TransactionMapper { ): Transaction['fees'] { const fees: Transaction['fees'] = []; - const tronAsset = Networks[network].nativeToken - const bandwidthAsset = Networks[network].bandwidth - const energyAsset = Networks[network].energy + const { + nativeToken: tronAsset, + bandwidth: bandwidthAsset, + energy: energyAsset, + } = Networks[network]; // Base TRX fee calculation const transactionFee = transactionInfo.ret.reduce( (total, result) => total + (result.fee || 0), 0, ); - const netFeeTotal = + const netFeeTotal = transactionFee + Number(transactionInfo.net_fee) + Number(transactionInfo.energy_fee); - if (netFeeTotal > 0) { - fees.push({ - type: 'base', - asset: { - type: tronAsset.id, - unit: tronAsset.symbol, - amount: netFeeTotal.toString(), - fungible: true, - }, - }); - } - - // Bandwidth usage as separate asset - const netUsage = Number(transactionInfo.net_usage); - if (netUsage > 0) { - fees.push({ - type: 'base', - asset: { - type: bandwidthAsset.id, - unit: bandwidthAsset.symbol, - amount: netUsage.toString(), - fungible: true, - }, - }); - } + const setFeeIfPresent = ( + amount: number, + asset: { id: CaipAssetType; symbol: string }, + ) => { + if (amount > 0) { + fees.push({ + type: 'base', + asset: { + type: asset.id, + unit: asset.symbol, + amount: amount.toString(), + fungible: true, + }, + }); + } + }; - // Energy usage as separate asset - const energyUsage = Number(transactionInfo.energy_usage_total); - if (energyUsage > 0) { - fees.push({ - type: 'base', - asset: { - type: energyAsset.id, - unit: energyAsset.symbol, - amount: energyUsage.toString(), - fungible: true, - }, - }); - } + setFeeIfPresent(transactionFee, tronAsset); + setFeeIfPresent(transactionInfo.net_usage, bandwidthAsset); + setFeeIfPresent(transactionInfo.energy_usage, energyAsset); return fees; } + /** * Maps a TransferContract (native TRX transfer) transaction. * @@ -106,9 +92,12 @@ export class TransactionMapper { // Convert from sun to TRX (divide by 10^6) const amountInSun = contractValue.amount; const amountInTrx = (amountInSun / 1_000_000).toString(); - + // Calculate comprehensive fees including Energy and Bandwidth - const fees = TransactionMapper.#calculateTronFees(scope, trongridTransaction); + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); let type: 'send' | 'receive' | 'unknown'; @@ -120,7 +109,7 @@ export class TransactionMapper { type = 'unknown'; } - const tronAsset = Networks[scope].nativeToken + const tronAsset = Networks[scope].nativeToken; return { type, @@ -189,9 +178,12 @@ export class TransactionMapper { const amountInSmallestUnit = contractValue.amount; const amountInReadableUnit = (amountInSmallestUnit / 1_000_000).toString(); const assetName = contractValue.asset_name; - + // Calculate comprehensive fees including Energy and Bandwidth - const fees = TransactionMapper.#calculateTronFees(scope, trongridTransaction); + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); let type: 'send' | 'receive' | 'unknown'; @@ -291,7 +283,10 @@ export class TransactionMapper { } // Calculate comprehensive fees including Energy and Bandwidth from raw transaction data - const fees = TransactionMapper.#calculateTronFees(scope, trongridTransaction); + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); return { type, diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts index 1b07a0e7..dd3dda38 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts @@ -1,17 +1,21 @@ -import { TransactionsService } from './TransactionsService'; -import type { TronKeyringAccount } from '../../entities'; -import { Network, KnownCaip19Id } from '../../constants'; -import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { TransactionsRepository } from './TransactionsRepository'; -import type { ILogger } from '../../utils/logger'; -import type { TransactionInfo, ContractTransactionInfo } from '../../clients/trongrid/types'; import type { Transaction } from '@metamask/keyring-api'; -// Import simplified mock data (each file now contains only one transaction) +import contractInfoMock from './mocks/contract-info.json'; import nativeTransferMock from './mocks/native-transfer.json'; import trc10TransferMock from './mocks/trc10-transfer.json'; import trc20TransferMock from './mocks/trc20-transfer.json'; -import contractInfoMock from './mocks/contract-info.json'; +import type { TransactionsRepository } from './TransactionsRepository'; +import { TransactionsService } from './TransactionsService'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { + TransactionInfo, + ContractTransactionInfo, +} from '../../clients/trongrid/types'; +import { Network, KnownCaip19Id } from '../../constants'; +import type { TronKeyringAccount } from '../../entities'; +import type { ILogger } from '../../utils/logger'; + +// Import simplified mock data (each file now contains only one transaction) describe('TransactionsService', () => { let transactionsService: TransactionsService; @@ -85,35 +89,36 @@ describe('TransactionsService', () => { describe('fetchTransactionsForAccount', () => { it('should fetch and map transactions for an account using native transfers mock data', async () => { // Setup mock responses with simplified single-transaction structure - mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue( - [nativeTransferMock] as TransactionInfo[] - ); + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + nativeTransferMock, + ] as TransactionInfo[]); mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( - contractInfoMock.data as ContractTransactionInfo[] + contractInfoMock.data as ContractTransactionInfo[], ); const result = await transactionsService.fetchTransactionsForAccount( Network.Mainnet, - mockAccount + mockAccount, ); console.log('Fetched transactions count:', result.length); - console.log('Sample fetched transactions:', JSON.stringify(result.slice(0, 2), null, 2)); + console.log( + 'Sample fetched transactions:', + JSON.stringify(result.slice(0, 2), null, 2), + ); // Verify API calls were made - expect(mockTrongridApiClient.getTransactionInfoByAddress).toHaveBeenCalledWith( - Network.Mainnet, - mockAccount.address - ); - expect(mockTrongridApiClient.getContractTransactionInfoByAddress).toHaveBeenCalledWith( - Network.Mainnet, - mockAccount.address - ); + expect( + mockTrongridApiClient.getTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + expect( + mockTrongridApiClient.getContractTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); // Verify logger calls expect(mockLogger.info).toHaveBeenCalledWith( '[🧾 TransactionsService]', - expect.stringContaining('Fetching transactions for account') + expect.stringContaining('Fetching transactions for account'), ); expect(true).toBe(true); @@ -121,28 +126,31 @@ describe('TransactionsService', () => { it('should fetch and map transactions for an account using TRC10 transfers mock data', async () => { // Setup mock responses with simplified single-transaction structure - mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue( - [trc10TransferMock] as TransactionInfo[] + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + trc10TransferMock, + ] as TransactionInfo[]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], ); - mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue([]); const result = await transactionsService.fetchTransactionsForAccount( Network.Mainnet, - mockAccount2 + mockAccount2, ); console.log('Fetched TRC10 transactions count:', result.length); - console.log('Sample fetched TRC10 transactions:', JSON.stringify(result.slice(0, 2), null, 2)); + console.log( + 'Sample fetched TRC10 transactions:', + JSON.stringify(result.slice(0, 2), null, 2), + ); // Verify API calls were made - expect(mockTrongridApiClient.getTransactionInfoByAddress).toHaveBeenCalledWith( - Network.Mainnet, - mockAccount2.address - ); - expect(mockTrongridApiClient.getContractTransactionInfoByAddress).toHaveBeenCalledWith( - Network.Mainnet, - mockAccount2.address - ); + expect( + mockTrongridApiClient.getTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount2.address); + expect( + mockTrongridApiClient.getContractTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount2.address); expect(true).toBe(true); }); @@ -150,22 +158,22 @@ describe('TransactionsService', () => { it('should handle network parameter correctly for different networks', async () => { // Setup mock responses mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([]); - mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue([]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); await transactionsService.fetchTransactionsForAccount( Network.Shasta, - mockAccount + mockAccount, ); // Verify API calls were made with correct network - expect(mockTrongridApiClient.getTransactionInfoByAddress).toHaveBeenCalledWith( - Network.Shasta, - mockAccount.address - ); - expect(mockTrongridApiClient.getContractTransactionInfoByAddress).toHaveBeenCalledWith( - Network.Shasta, - mockAccount.address - ); + expect( + mockTrongridApiClient.getTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Shasta, mockAccount.address); + expect( + mockTrongridApiClient.getContractTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Shasta, mockAccount.address); expect(true).toBe(true); }); @@ -173,11 +181,13 @@ describe('TransactionsService', () => { it('should handle empty responses from API', async () => { // Setup empty mock responses mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([]); - mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue([]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); const result = await transactionsService.fetchTransactionsForAccount( Network.Mainnet, - mockAccount + mockAccount, ); console.log('Empty API response result:', result); @@ -188,10 +198,15 @@ describe('TransactionsService', () => { it('should handle API errors gracefully', async () => { // Setup API to throw error const apiError = new Error('API request failed'); - mockTrongridApiClient.getTransactionInfoByAddress.mockRejectedValue(apiError); + mockTrongridApiClient.getTransactionInfoByAddress.mockRejectedValue( + apiError, + ); await expect( - transactionsService.fetchTransactionsForAccount(Network.Mainnet, mockAccount) + transactionsService.fetchTransactionsForAccount( + Network.Mainnet, + mockAccount, + ), ).rejects.toThrow('API request failed'); expect(true).toBe(true); @@ -208,8 +223,28 @@ describe('TransactionsService', () => { chain: Network.Mainnet, status: 'confirmed', timestamp: Date.now(), - from: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], - to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + from: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], events: [], fees: [], }, @@ -223,8 +258,28 @@ describe('TransactionsService', () => { chain: Network.Mainnet, status: 'confirmed', timestamp: Date.now(), - from: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '50', unit: 'TRX', fungible: true } }], - to: [{ address: mockAccount2.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '50', unit: 'TRX', fungible: true } }], + from: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '50', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: mockAccount2.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '50', + unit: 'TRX', + fungible: true, + }, + }, + ], events: [], fees: [], }, @@ -234,13 +289,25 @@ describe('TransactionsService', () => { .mockResolvedValueOnce(mockTransactions1) .mockResolvedValueOnce(mockTransactions2); - const result = await transactionsService.findByAccounts([mockAccount, mockAccount2]); + const result = await transactionsService.findByAccounts([ + mockAccount, + mockAccount2, + ]); + + console.log( + 'Found transactions for multiple accounts:', + JSON.stringify(result, null, 2), + ); - console.log('Found transactions for multiple accounts:', JSON.stringify(result, null, 2)); - - expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledTimes(2); - expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledWith(mockAccount.id); - expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledWith(mockAccount2.id); + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledTimes( + 2, + ); + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledWith( + mockAccount2.id, + ); expect(result).toHaveLength(2); expect(true).toBe(true); }); @@ -264,8 +331,28 @@ describe('TransactionsService', () => { chain: Network.Mainnet, status: 'confirmed', timestamp: Date.now(), - from: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], - to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + from: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], events: [], fees: [], }; @@ -273,7 +360,9 @@ describe('TransactionsService', () => { await transactionsService.save(mockTransaction); console.log('Saved single transaction:', mockTransaction.id); - expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([mockTransaction]); + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([ + mockTransaction, + ]); expect(true).toBe(true); }); }); @@ -288,8 +377,28 @@ describe('TransactionsService', () => { chain: Network.Mainnet, status: 'confirmed', timestamp: Date.now(), - from: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], - to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + from: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], events: [], fees: [], }, @@ -300,8 +409,28 @@ describe('TransactionsService', () => { chain: Network.Mainnet, status: 'confirmed', timestamp: Date.now(), - from: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '50', unit: 'TRX', fungible: true } }], - to: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '50', unit: 'TRX', fungible: true } }], + from: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '50', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '50', + unit: 'TRX', + fungible: true, + }, + }, + ], events: [], fees: [], }, @@ -309,10 +438,18 @@ describe('TransactionsService', () => { await transactionsService.saveMany(mockTransactions); - console.log('Saved multiple transactions count:', mockTransactions.length); - console.log('Saved transactions IDs:', mockTransactions.map(tx => tx.id)); - - expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith(mockTransactions); + console.log( + 'Saved multiple transactions count:', + mockTransactions.length, + ); + console.log( + 'Saved transactions IDs:', + mockTransactions.map((tx) => tx.id), + ); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( + mockTransactions, + ); expect(true).toBe(true); }); @@ -333,8 +470,28 @@ describe('TransactionsService', () => { chain: Network.Mainnet, status: 'confirmed', timestamp: Date.now(), - from: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], - to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '100', unit: 'TRX', fungible: true } }], + from: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], events: [], fees: [], }, @@ -345,8 +502,28 @@ describe('TransactionsService', () => { chain: Network.Mainnet, status: 'confirmed', timestamp: Date.now(), - from: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '25', unit: 'TRX', fungible: true } }], - to: [{ address: mockAccount.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '25', unit: 'TRX', fungible: true } }], + from: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '25', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '25', + unit: 'TRX', + fungible: true, + }, + }, + ], events: [], fees: [], }, @@ -357,8 +534,28 @@ describe('TransactionsService', () => { chain: Network.Mainnet, status: 'confirmed', timestamp: Date.now(), - from: [{ address: mockAccount2.address, asset: { type: KnownCaip19Id.TrxMainnet, amount: '75', unit: 'TRX', fungible: true } }], - to: [{ address: 'other-address', asset: { type: KnownCaip19Id.TrxMainnet, amount: '75', unit: 'TRX', fungible: true } }], + from: [ + { + address: mockAccount2.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '75', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '75', + unit: 'TRX', + fungible: true, + }, + }, + ], events: [], fees: [], }, @@ -370,7 +567,9 @@ describe('TransactionsService', () => { console.log(`Account ${mockAccount.id}: 2 transactions`); console.log(`Account ${mockAccount2.id}: 1 transaction`); - expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith(mockTransactions); + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( + mockTransactions, + ); expect(true).toBe(true); }); }); @@ -378,26 +577,35 @@ describe('TransactionsService', () => { describe('Integration scenarios', () => { it('should handle a complete flow: fetch, process, and save transactions', async () => { // Setup API responses with simplified single-transaction structure - mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue( - [nativeTransferMock] as TransactionInfo[] - ); + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + nativeTransferMock, + ] as TransactionInfo[]); mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( - contractInfoMock.data.slice(0, 1) as ContractTransactionInfo[] + contractInfoMock.data.slice(0, 1) as ContractTransactionInfo[], ); // Fetch transactions - const fetchedTransactions = await transactionsService.fetchTransactionsForAccount( - Network.Mainnet, - mockAccount - ); + const fetchedTransactions = + await transactionsService.fetchTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); // Save the fetched transactions await transactionsService.saveMany(fetchedTransactions); - console.log('Complete flow - Fetched and saved transactions:', fetchedTransactions.length); - console.log('Sample transaction IDs:', fetchedTransactions.slice(0, 2).map(tx => tx.id)); + console.log( + 'Complete flow - Fetched and saved transactions:', + fetchedTransactions.length, + ); + console.log( + 'Sample transaction IDs:', + fetchedTransactions.slice(0, 2).map((tx) => tx.id), + ); - expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith(fetchedTransactions); + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( + fetchedTransactions, + ); expect(true).toBe(true); }); @@ -405,20 +613,27 @@ describe('TransactionsService', () => { // Mix different types of transactions with simplified structure const mixedRawTransactions = [ nativeTransferMock, // Native TRX transfer - trc10TransferMock, // TRC10 transfer - trc20TransferMock, // TRC20 transfer + trc10TransferMock, // TRC10 transfer + trc20TransferMock, // TRC20 transfer ] as TransactionInfo[]; - mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue(mixedRawTransactions); - mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue([]); + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue( + mixedRawTransactions, + ); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); const result = await transactionsService.fetchTransactionsForAccount( Network.Mainnet, - mockAccount2 + mockAccount2, ); console.log('Mixed transaction types result:', result.length); - console.log('Transaction types:', result.map(tx => tx.type)); + console.log( + 'Transaction types:', + result.map((tx) => tx.type), + ); expect(true).toBe(true); }); From 367909b5f25bd72725c748d0bf6f014cec457ef7 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 18 Sep 2025 16:40:42 +0100 Subject: [PATCH 026/238] feat: send Energy and Bandwidth as assets (#27) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/tron-http/TronHttpClient.ts | 40 ++ .../src/clients/tron-http/types.ts | 12 + .../tron-wallet-snap/src/entities/assets.ts | 13 +- .../tron-wallet-snap/src/handlers/assets.ts | 12 +- .../src/services/accounts/AccountsService.ts | 4 +- .../src/services/assets/AssetsRepository.ts | 61 ++- .../src/services/assets/AssetsService.ts | 324 ++++++++++++---- .../src/services/assets/types.ts | 10 + .../src/services/state/State.ts | 9 + .../transactions/TransactionsMapper.test.ts | 365 +++++++++++++----- .../transactions/TransactionsMapper.ts | 9 +- .../transactions/TransactionsService.test.ts | 4 +- .../transactions/mocks/contract-info.json | 98 ++--- .../transactions/mocks/native-transfer.json | 76 ++-- .../transactions/mocks/trc10-transfer.json | 78 ++-- .../transactions/mocks/trc20-transfer.json | 78 ++-- 17 files changed, 817 insertions(+), 378 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index b5794ecb..7c6bbe8a 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "ma/N3PNnetiVUQDihugFV1xtD3FXaO5iehab1IsmgPc=", + "shasum": "HvFu/qDLERf0AdFdzMSqByw7iGOjOEnZMg+ih8Eh2uE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts index 1f5c024c..8de6fa19 100644 --- a/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts @@ -1,4 +1,5 @@ import type { + AccountResources, TRC10TokenInfo, TRC10TokenMetadata, TRC20TokenMetadata, @@ -341,4 +342,43 @@ export class TronHttpClient { return results; } + + /** + * Get account resources (Energy and Bandwidth) + * + * @see https://developers.tron.network/reference/getaccountresource + * @param network - Network to query + * @param accountAddress - Account address in base58 format + * @returns Promise - Account resources + */ + async getAccountResources( + network: Network, + accountAddress: string, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = `${baseUrl}/wallet/getaccountresource`; + + const body = JSON.stringify({ + address: accountAddress, + visible: true, + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const accountResources: AccountResources = await response.json(); + return accountResources; + } } diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts index 0d5fbd09..0efa61d3 100644 --- a/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts @@ -91,3 +91,15 @@ export type TRC10TokenMetadata = { symbol: string; decimals: number; }; + +export type AccountResources = { + freeNetLimit: number; + NetLimit: number; + TotalNetLimit: number; + TotalNetWeight: number; + tronPowerUsed: number; + tronPowerLimit: number; + EnergyLimit: number; + TotalEnergyLimit: number; + TotalEnergyWeight: number; +}; diff --git a/merged-packages/tron-wallet-snap/src/entities/assets.ts b/merged-packages/tron-wallet-snap/src/entities/assets.ts index 4f678c4e..8d9047c9 100644 --- a/merged-packages/tron-wallet-snap/src/entities/assets.ts +++ b/merged-packages/tron-wallet-snap/src/entities/assets.ts @@ -39,4 +39,15 @@ export type NftAsset = { uiAmount: string; // With decimals }; -export type AssetEntity = NativeAsset | TokenAsset | NftAsset; +export type ResourceAsset = { + assetType: `${Network}/slip44:energy` | `${Network}/slip44:bandwidth`; + keyringAccountId: string; + network: Network; + mint: string; + pubkey: string; + symbol: string; + rawAmount: string; // Without decimals + uiAmount: string; // With decimals +}; + +export type AssetEntity = NativeAsset | TokenAsset | NftAsset | ResourceAsset; diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts index 13d7633d..53cf4c65 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/assets.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -47,8 +47,18 @@ export class AssetsHandler { const { conversions } = params; + const filteredConversions = conversions.filter( + (conversion) => + !conversion.from.endsWith('/slip44:energy') && + !conversion.from.endsWith('/slip44:bandwidth') && + !conversion.to.endsWith('/slip44:energy') && + !conversion.to.endsWith('/slip44:bandwidth'), + ); + const conversionRates = - await this.#assetsService.getMultipleTokenConversions(conversions); + await this.#assetsService.getMultipleTokenConversions( + filteredConversions, + ); return { conversionRates, diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index c4139b39..b68e3429 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -137,9 +137,9 @@ export class AccountsService { }): Promise { const derivationPath = customDerivationPath ?? - (index !== undefined ? this.#getDefaultDerivationPath(index) : undefined); + (index === undefined ? undefined : this.#getDefaultDerivationPath(index)); - if (!derivationPath) { + if (derivationPath === undefined) { throw new Error('Either index or derivationPath must be provided'); } diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts index 25326c48..c53846e2 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts @@ -1,31 +1,58 @@ +import { cloneDeep } from 'lodash'; + import type { AssetEntity } from '../../entities/assets'; -import type { State, UnencryptedStateValue } from '../state/State'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; export class AssetsRepository { - readonly #storageKey = 'assets'; - - readonly #state: State; + readonly #state: IStateManager; - constructor(state: State) { + constructor(state: IStateManager) { this.#state = state; } - async getByAccountId(accountId: string): Promise { - return ( - (await this.#state.getKey( - `${this.#storageKey}.${accountId}`, - )) ?? [] + async getByAccountId(keyringAccountId: string): Promise { + const assets = await this.#state.getKey( + `assets.${keyringAccountId}`, ); - } - async saveMany(assets: AssetEntity[]): Promise { - return this.#state.setKey(this.#storageKey, { - ...(await this.getAll()), - ...assets, - }); + return assets ?? []; } async getAll(): Promise { - return (await this.#state.getKey(this.#storageKey)) ?? []; + const assetsByAccount = + (await this.#state.getKey('assets')) ?? + {}; + + return Object.values(assetsByAccount).flat(); + } + + async saveMany(assets: AssetEntity[]): Promise { + // Update the state atomically + await this.#state.update((stateValue) => { + const newState = cloneDeep(stateValue); + for (const asset of assets) { + const { keyringAccountId } = asset; + const accountAssets = cloneDeep( + newState.assets[keyringAccountId] ?? [], + ); + + // Avoid duplicates. If same asset is already saved, override it. + const existingAssetIndex = accountAssets.findIndex( + (item) => + item.assetType === asset.assetType && + item.keyringAccountId === asset.keyringAccountId, + ); + + if (existingAssetIndex === -1) { + accountAssets.push(asset); + } else { + accountAssets[existingAssetIndex] = asset; + } + + newState.assets[keyringAccountId] = accountAssets; + } + return newState; + }); } } diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index a9d5e661..1e416d12 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -21,15 +21,17 @@ import type { NativeCaipAssetType, NftCaipAssetType, TokenCaipAssetType, + ResourceCaipAssetType, } from './types'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { FiatTicker, SpotPrice } from '../../clients/price-api/types'; +import type { AccountResources } from '../../clients/tron-http'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; import type { TronAccount } from '../../clients/trongrid/types'; -import type { Network } from '../../constants'; +import { Networks, type Network } from '../../constants'; import { configProvider } from '../../context'; -import type { AssetEntity } from '../../entities/assets'; +import type { AssetEntity, ResourceAsset } from '../../entities/assets'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; import type { State, UnencryptedStateValue } from '../state/State'; @@ -87,17 +89,26 @@ export class AssetsService { scope, }); - const tronAccountInfo = - await this.#trongridApiClient.getAccountInfoByAddress( - scope, - account.address, - ); + const [tronAccountInfo, tronAccountResources] = await Promise.all([ + this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), + this.#tronHttpClient.getAccountResources(scope, account.address), + ]); const nativeAsset = this.#extractNativeAsset({ account, scope, tronAccountInfo, }); + const energyAsset = this.#extractEnergy({ + account, + scope, + tronAccountResources, + }); + const bandwidthAsset = this.#extractBandwidth({ + account, + scope, + tronAccountResources, + }); const trc10Assets = this.#extractTrc10Assets({ account, scope, @@ -111,33 +122,42 @@ export class AssetsService { const assetTypes = [ nativeAsset.assetType, + energyAsset.assetType, + bandwidthAsset.assetType, ...trc10Assets.map((tokenAsset) => tokenAsset.assetType), ...trc20Assets.map((tokenAsset) => tokenAsset.assetType), ]; const assetsMetadata = await this.getAssetsMetadata(assetTypes); - const assets = [nativeAsset, ...trc10Assets, ...trc20Assets].map( - (asset) => { - const metadata = assetsMetadata[asset.assetType]; - const mergedAsset = { - ...asset, - ...metadata, - } as any; - - const unit = metadata?.fungible ? metadata.units?.[0] : undefined; - - if (unit) { - const decimals = new BigNumber(mergedAsset.rawAmount).dividedBy( - new BigNumber(10).pow(unit.decimals), - ); - mergedAsset.uiAmount = decimals.toString(); - } else { - mergedAsset.uiAmount = '0'; - } + const assets = [ + nativeAsset, + energyAsset, + bandwidthAsset, + ...trc10Assets, + ...trc20Assets, + ].map((asset) => { + const metadata = assetsMetadata[asset.assetType]; + const mergedAsset = { + ...asset, + ...metadata, + } as any; + + const unit = metadata?.fungible ? metadata.units?.[0] : undefined; + + if (unit) { + const decimals = new BigNumber(mergedAsset.rawAmount).dividedBy( + new BigNumber(10).pow(unit.decimals), + ); + mergedAsset.uiAmount = decimals.toString(); + } else { + mergedAsset.uiAmount = '0'; + } - return mergedAsset; - }, - ); + return mergedAsset; + }); + + console.log('[AssetsService]Fetch assets and balances for account'); + console.log(JSON.stringify(assets)); return assets; } @@ -171,6 +191,57 @@ export class AssetsService { return asset; } + #extractEnergy({ + account, + scope, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources; + }): AssetEntity { + const energy = tronAccountResources.EnergyLimit; + + const asset: ResourceAsset = { + assetType: Networks[scope].energy.id, + keyringAccountId: account.id, + network: scope, + mint: '', + pubkey: '', + symbol: 'ENERGY', + rawAmount: energy.toString(), + uiAmount: energy.toString(), + }; + + return asset; + } + + #extractBandwidth({ + account, + scope, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources; + }): AssetEntity { + const bandwidth = + tronAccountResources.freeNetLimit + tronAccountResources.NetLimit; + + const asset: AssetEntity = { + assetType: Networks[scope].bandwidth.id, + keyringAccountId: account.id, + network: scope, + mint: '', + pubkey: '', + symbol: 'BANDWIDTH', + rawAmount: bandwidth.toString(), + uiAmount: bandwidth.toString(), + }; + + return asset; + } + #extractTrc10Assets({ account, scope, @@ -238,18 +309,32 @@ export class AssetsService { ): Promise> { this.#logger.info('Fetching metadata for assets', assetTypes); - const { nativeAssetTypes, tokenTrc10AssetTypes, tokenTrc20AssetTypes } = - this.#splitAssetsByType(assetTypes); - - const [nativeTokensMetadata, trc10TokensMetadata, trc20TokensMetadata] = - await Promise.all([ - this.#getNativeTokensMetadata(nativeAssetTypes), - this.#getTRC10TokensMetadata(tokenTrc10AssetTypes), - this.#getTRC20TokensMetadata(tokenTrc20AssetTypes), - ]); + const { + nativeAssetTypes, + energyAssetTypes, + bandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + } = this.#splitAssetsByType(assetTypes); + + const [ + nativeTokensMetadata, + energyTokensMetadata, + bandwidthTokensMetadata, + trc10TokensMetadata, + trc20TokensMetadata, + ] = await Promise.all([ + this.#getNativeTokensMetadata(nativeAssetTypes), + this.#getEnergyMetadata(energyAssetTypes), + this.#getBandwidthMetadata(bandwidthAssetTypes), + this.#getTRC10TokensMetadata(tokenTrc10AssetTypes), + this.#getTRC20TokensMetadata(tokenTrc20AssetTypes), + ]); const result = { ...nativeTokensMetadata, + ...energyTokensMetadata, + ...bandwidthTokensMetadata, ...trc10TokensMetadata, ...trc20TokensMetadata, }; @@ -261,6 +346,8 @@ export class AssetsService { #splitAssetsByType(assetTypes: CaipAssetType[]): { nativeAssetTypes: NativeCaipAssetType[]; + energyAssetTypes: ResourceCaipAssetType[]; + bandwidthAssetTypes: ResourceCaipAssetType[]; tokenTrc10AssetTypes: TokenCaipAssetType[]; tokenTrc20AssetTypes: TokenCaipAssetType[]; nftAssetTypes: NftCaipAssetType[]; @@ -268,6 +355,12 @@ export class AssetsService { const nativeAssetTypes = assetTypes.filter((assetType) => assetType.endsWith('/slip44:195'), ) as NativeCaipAssetType[]; + const energyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:energy'), + ) as ResourceCaipAssetType[]; + const bandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:bandwidth'), + ) as ResourceCaipAssetType[]; const tokenTrc10AssetTypes = assetTypes.filter((assetType) => assetType.includes('/trc10:'), ) as TokenCaipAssetType[]; @@ -280,6 +373,8 @@ export class AssetsService { return { nativeAssetTypes, + energyAssetTypes, + bandwidthAssetTypes, tokenTrc10AssetTypes, tokenTrc20AssetTypes, nftAssetTypes, @@ -316,6 +411,66 @@ export class AssetsService { return nativeTokensMetadata; } + #getEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const energyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + // const { chainId } = parseCaipAssetType(assetType); + energyTokensMetadata[assetType] = { + name: 'Energy', + symbol: 'ENERGY', + fungible: true as const, + // iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'Energy', + symbol: 'ENERGY', + decimals: 0, + }, + ], + }; + } + + return energyTokensMetadata; + } + + #getBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const bandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + // const { chainId } = parseCaipAssetType(assetType); + bandwidthTokensMetadata[assetType] = { + name: 'Bandwidth', + symbol: 'BANDWIDTH', + fungible: true as const, + // iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'Bandwidth', + symbol: 'BANDWIDTH', + decimals: 0, + }, + ], + }; + } + + return bandwidthTokensMetadata; + } + async #getTRC10TokensMetadata( assetTypes: TokenCaipAssetType[], ): Promise> { @@ -420,14 +575,15 @@ export class AssetsService { async saveMany(assets: AssetEntity[]): Promise { this.#logger.info('Saving assets', assets); - const savedAssets = await this.getAll(); + const hasZeroAmount = (asset: AssetEntity): boolean => + asset.rawAmount === '0' || asset.uiAmount === '0'; + const hasNonZeroAmount = (asset: AssetEntity): boolean => + !hasZeroAmount(asset); - this.#logger.info('Saved assets', { savedAssets }); + const savedAssets = await this.getAll(); - const hasZeroRawAmount = (asset: AssetEntity): boolean => - asset.rawAmount === '0'; - const hasNonZeroRawAmount = (asset: AssetEntity): boolean => - !hasZeroRawAmount(asset); + // Save assets using repository + await this.#assetsRepository.saveMany(assets); // Notify the extension about the new assets in a single event const isNew = (asset: AssetEntity): boolean => @@ -437,16 +593,21 @@ export class AssetsService { item.assetType === asset.assetType, ); - const wasSavedWithZeroRawAmount = (asset: AssetEntity): boolean => { + const wasSavedWithZeroAmount = ( + asset: AssetEntity, + ): boolean | undefined => { const savedAsset = savedAssets.find( (item) => item.keyringAccountId === asset.keyringAccountId && item.assetType === asset.assetType, ); - return savedAsset !== undefined && hasZeroRawAmount(savedAsset); + return savedAsset && hasZeroAmount(savedAsset); }; + const isNativeAsset = (asset: AssetEntity): boolean => + asset.assetType.includes('/slip44:195'); + const assetListUpdatedPayload = assets.reduce< AccountAssetListUpdatedEvent['params']['assets'] >( @@ -455,43 +616,33 @@ export class AssetsService { [asset.keyringAccountId]: { added: [ ...(acc[asset.keyringAccountId]?.added ?? []), - ...((isNew(asset) || wasSavedWithZeroRawAmount(asset)) && - hasNonZeroRawAmount(asset) + ...((isNew(asset) || wasSavedWithZeroAmount(asset)) && + hasNonZeroAmount(asset) ? [asset.assetType] : []), ], removed: [ ...(acc[asset.keyringAccountId]?.removed ?? []), - ...(hasZeroRawAmount(asset) ? [asset.assetType] : []), + ...(hasZeroAmount(asset) && !isNativeAsset(asset) // Never remove native assets from the account asset list + ? [asset.assetType] + : []), ], }, }), {}, ); + // If no assets were added or removed, don't emit the event. const isEmptyAccountAssetListUpdatedPayload = Object.values( assetListUpdatedPayload, ) .map((item) => item.added.length + item.removed.length) .every((item) => item === 0); - if (isEmptyAccountAssetListUpdatedPayload) { - this.#logger.info('No account asset list updated', { - assetListUpdatedPayload, - }); - } else { - this.#logger.info('Updating account asset list', { - isEmptyAccountAssetListUpdatedPayload, - assetListUpdatedPayload, - }); - + if (!isEmptyAccountAssetListUpdatedPayload) { await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { assets: assetListUpdatedPayload, }); - - this.#logger.info('Account asset list updated', { - assetListUpdatedPayload, - }); } // Notify the extension about the changed balances in a single event @@ -499,8 +650,32 @@ export class AssetsService { const hasChanged = (asset: AssetEntity): boolean => AssetsService.hasChanged(asset, savedAssets); + /** + * Build the event payload for snap keyring event `AccountBalancesUpdated`. + * + * @example + * { + * "balances": { + * "keyringAccountId0": { + * "assetType00": { + * "unit": "XYZ", + * "amount": "1234" + * }, + * "assetType01": { + * "unit": "ABC", + * "amount": "5678" + * } + * }, + * "keyringAccountId1": { + * "assetType10": { + * "unit": "XYZ", + * "amount": "42" + * } + * } + * } + * } + */ const balancesUpdatedPayload = assets - .filter(hasNonZeroRawAmount) .filter(hasChanged) .reduce( (acc, asset) => ({ @@ -515,28 +690,17 @@ export class AssetsService { }), {}, ); - const isEmptyAccountBalancesUpdatedPayload = Object.values( - balancesUpdatedPayload, - ) - .map((item) => Object.keys(item).length) - .every((item) => item === 0); - if (isEmptyAccountBalancesUpdatedPayload) { - this.#logger.info('No account balances updated', { - balancesUpdatedPayload, - }); - } else { - this.#logger.info('Updating account balances', { - balancesUpdatedPayload, - }); + // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. + const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) + .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes + .some((count) => count > 0); + // Only emit the event if some balance was changed. + if (isSomeBalanceChanged) { await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { balances: balancesUpdatedPayload, }); - - this.#logger.info('Account balances updated', { - balancesUpdatedPayload, - }); } } diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts index 3f6987df..b720fd12 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -2,6 +2,8 @@ import type { TrxScope } from '@metamask/keyring-api'; import { pattern, string } from '@metamask/superstruct'; export type NativeCaipAssetType = `${TrxScope}/slip44:195`; +export type ResourceCaipAssetType = + `${TrxScope}/slip44:${'energy' | 'bandwidth'}`; export type TokenCaipAssetType = `${TrxScope}/${'trc10' | 'trc20'}:${string}`; export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; @@ -13,6 +15,14 @@ export const NativeCaipAssetTypeStruct = pattern( /^tron:(mainnet|nile|shasta)\/slip44:195$/u, ); +/** + * Validates a TRON native CAIP-19 ID for resources (e.g., "tron:mainnet/energy" or "tron:mainnet/bandwidth") + */ +export const ResourceCaipAssetTypeStruct = pattern( + string(), + /^tron:(mainnet|nile|shasta)\/slip44:(energy|bandwidth)$/u, +); + /** * Validates a TRON token CAIP-19 ID (e.g., "tron:mainnet/trc10:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t") */ diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.ts b/merged-packages/tron-wallet-snap/src/services/state/State.ts index 0c8e31b6..8ed05ea1 100644 --- a/merged-packages/tron-wallet-snap/src/services/state/State.ts +++ b/merged-packages/tron-wallet-snap/src/services/state/State.ts @@ -130,4 +130,13 @@ export class State> return state; }); } + + async deleteKeys(keys: string[]): Promise { + await this.update((state) => { + keys.forEach((key) => { + unset(state, key); + }); + return state; + }); + } } diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts index d1260379..33932f19 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts @@ -1,3 +1,4 @@ +// eslint-disable-file @typescript-eslint/no-non-null-assertion import { TransactionMapper } from './TransactionsMapper'; import type { TransactionInfo, @@ -5,8 +6,6 @@ import type { } from '../../clients/trongrid/types'; import { Network } from '../../constants'; import type { TronKeyringAccount } from '../../entities'; - -// Import simplified mock data (each file now contains only one transaction) import contractInfoMock from './mocks/contract-info.json'; import nativeTransferMock from './mocks/native-transfer.json'; import trc10TransferMock from './mocks/trc10-transfer.json'; @@ -41,30 +40,55 @@ describe('TransactionMapper', () => { JSON.stringify(result, null, 2), ); - expect(result).toBeDefined(); - expect(result).not.toBeNull(); - expect(result!.type).toBe('send'); - expect(result!.id).toBe( - '8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b', - ); - expect(result!.from).toHaveLength(1); - expect(result!.from[0]?.address).toBe( - 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', - ); - expect((result!.from[0]?.asset as any)?.amount).toBe('0.01'); - expect((result!.from[0]?.asset as any)?.unit).toBe('TRX'); - expect((result!.from[0]?.asset as any)?.type).toBe( - 'tron:728126428/slip44:195', - ); - expect(result!.to).toHaveLength(1); - expect(result!.to[0]?.address).toBe( - 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', - ); - expect(result!.chain).toBe('tron:728126428'); - expect(result!.status).toBe('confirmed'); - expect(result!.fees).toHaveLength(1); - expect((result!.fees[0]?.asset as any)?.amount).toBe('532000'); // Actual fee from mock - expect((result!.fees[0]?.asset as any)?.unit).toBe('TRX'); + const expectedTransaction = { + account: 'test-account-id', + type: 'send', + id: '8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b', + from: [ + { + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + asset: { + amount: '0.01', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + asset: { + amount: '0.01', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + }, + ], + chain: 'tron:728126428', + status: 'confirmed', + timestamp: 1756914747000, + events: [ + { + status: 'confirmed', + timestamp: 1756914747000, + }, + ], + fees: [ + { + asset: { + amount: '266000', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + ], + }; + + expect(result).toStrictEqual(expectedTransaction); }); }); @@ -90,27 +114,55 @@ describe('TransactionMapper', () => { JSON.stringify(result, null, 2), ); - expect(result).toBeDefined(); - expect(result).not.toBeNull(); - expect(result!.type).toBe('send'); - expect(result!.id).toBe( - 'd8fc96d5b81fe600e055741e27135e22d5ae42584c9056758f797b1a20328818', - ); - expect(result!.from).toHaveLength(1); - expect(result!.from[0]?.address).toBe( - 'TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw', - ); - expect((result!.from[0]?.asset as any)?.amount).toBe('0.494'); - expect((result!.from[0]?.asset as any)?.unit).toBe('UNKNOWN'); - expect((result!.from[0]?.asset as any)?.type).toBe( - 'tron:728126428/trc10:1002000', - ); - expect(result!.to).toHaveLength(1); - expect(result!.chain).toBe('tron:728126428'); - expect(result!.status).toBe('confirmed'); - expect(result!.fees).toHaveLength(1); - expect((result!.fees[0]?.asset as any)?.amount).toBe('562000'); // Actual TRC10 fee - expect((result!.fees[0]?.asset as any)?.unit).toBe('TRX'); + const expectedTransaction = { + account: 'test-trc10-account', + type: 'send', + id: 'd8fc96d5b81fe600e055741e27135e22d5ae42584c9056758f797b1a20328818', + from: [ + { + address: 'TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw', + asset: { + amount: '0.494', + unit: 'UNKNOWN', + type: 'tron:728126428/trc10:1002000', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TKYT8YiiL58h8USHkmVEhCYpNfgSyiWPcW', + asset: { + amount: '0.494', + unit: 'UNKNOWN', + type: 'tron:728126428/trc10:1002000', + fungible: true, + }, + }, + ], + chain: 'tron:728126428', + status: 'confirmed', + timestamp: 1756870677000, + events: [ + { + status: 'confirmed', + timestamp: 1756870677000, + }, + ], + fees: [ + { + asset: { + amount: '281000', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + ], + }; + + expect(result).toStrictEqual(expectedTransaction); }); }); @@ -132,35 +184,73 @@ describe('TransactionMapper', () => { JSON.stringify(result, null, 2), ); - expect(result).toBeDefined(); - expect(result).not.toBeNull(); - expect(result!.type).toBe('send'); - expect(result!.id).toBe( - '35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa', - ); - expect(result!.from).toHaveLength(1); - expect(result!.from[0]?.address).toBe( - 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', - ); - expect((result!.from[0]?.asset as any)?.amount).toBe('0.01'); - expect((result!.from[0]?.asset as any)?.unit).toBe('USDT'); - expect((result!.from[0]?.asset as any)?.type).toBe( - 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', - ); - expect(result!.to).toHaveLength(1); - expect(result!.to[0]?.address).toBe( - 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', - ); - expect(result!.chain).toBe('tron:728126428'); - expect(result!.status).toBe('confirmed'); - // Comprehensive fee structure: TRX fee + Bandwidth + Energy - expect(result!.fees).toHaveLength(3); - expect((result!.fees[0]?.asset as any)?.amount).toBe('25975600'); // Actual TRC20 TRX fee - expect((result!.fees[0]?.asset as any)?.unit).toBe('TRX'); - expect((result!.fees[1]?.asset as any)?.unit).toBe('BANDWIDTH'); - expect((result!.fees[1]?.asset as any)?.amount).toBe('345'); - expect((result!.fees[2]?.asset as any)?.unit).toBe('ENERGY'); - expect((result!.fees[2]?.asset as any)?.amount).toBe('130285'); // Actual energy usage from console + const expectedTransaction = { + account: 'test-account-id', + type: 'send', + id: '35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa', + from: [ + { + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + asset: { + amount: '0.01', + unit: 'USDT', + type: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + asset: { + amount: '0.01', + unit: 'USDT', + type: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + fungible: true, + }, + }, + ], + chain: 'tron:728126428', + status: 'confirmed', + timestamp: 1757590707000, + events: [ + { + status: 'confirmed', + timestamp: 1757590707000, + }, + ], + fees: [ + { + asset: { + amount: '12987800', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + { + asset: { + amount: '345', + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + fungible: true, + }, + type: 'base', + }, + { + asset: { + amount: '407', + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + fungible: true, + }, + type: 'base', + }, + ], + }; + + expect(result).toStrictEqual(expectedTransaction); }); it('should return null for TriggerSmartContract without assistance data', () => { @@ -192,11 +282,20 @@ describe('TransactionMapper', () => { }); console.log('Native TRX fees:', JSON.stringify(result?.fees, null, 2)); - expect(result).not.toBeNull(); - expect(result?.fees).toBeDefined(); - expect(result?.fees).toHaveLength(1); - expect((result?.fees[0]?.asset as any)?.amount).toBe('532000'); // Actual net_fee from mock - expect((result?.fees[0]?.asset as any)?.unit).toBe('TRX'); + + const expectedFees = [ + { + asset: { + amount: '266000', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + ]; + + expect(result?.fees).toStrictEqual(expectedFees); }); it('should calculate comprehensive fees for TRC20 transfers', () => { @@ -215,20 +314,38 @@ describe('TransactionMapper', () => { 'TRC20 comprehensive fees:', JSON.stringify(result?.fees, null, 2), ); - expect(result).not.toBeNull(); - expect(result?.fees).toBeDefined(); - expect(result?.fees).toHaveLength(3); - // TRX fee - expect((result?.fees[0]?.asset as any)?.amount).toBe('25975600'); // Actual TRC20 fee - expect((result?.fees[0]?.asset as any)?.unit).toBe('TRX'); - - // Bandwidth fee - expect((result?.fees[1]?.asset as any)?.amount).toBe('345'); - expect((result?.fees[1]?.asset as any)?.unit).toBe('BANDWIDTH'); + const expectedFees = [ + { + asset: { + amount: '12987800', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + { + asset: { + amount: '345', + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + fungible: true, + }, + type: 'base', + }, + { + asset: { + amount: '407', + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + fungible: true, + }, + type: 'base', + }, + ]; - // Energy fee - expect((result?.fees[2]?.asset as any)?.amount).toBe('130285'); // Actual energy from console + expect(result?.fees).toStrictEqual(expectedFees); }); }); }); @@ -278,7 +395,7 @@ describe('TransactionMapper', () => { }); console.log('Empty input result:', result); - expect(result).toEqual([]); + expect(result).toStrictEqual([]); }); }); @@ -286,6 +403,7 @@ describe('TransactionMapper', () => { it('should handle transaction with missing contract data', () => { const malformedTransaction = { txID: 'test-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention raw_data: { contract: undefined, }, @@ -305,6 +423,7 @@ describe('TransactionMapper', () => { const mockRawData = nativeTransferMock?.raw_data; const rawTransaction = { ...nativeTransferMock, + // eslint-disable-next-line @typescript-eslint/naming-convention raw_data: { ...mockRawData, contract: [ @@ -342,15 +461,55 @@ describe('TransactionMapper', () => { JSON.stringify(result, null, 2), ); - expect(result).toBeDefined(); - expect(result).not.toBeNull(); - expect(result!.chain).toBe('tron:2494104990'); // Shasta chain ID - expect((result!.from[0]?.asset as any)?.type).toBe( - 'tron:2494104990/slip44:195', - ); - expect((result!.to[0]?.asset as any)?.type).toBe( - 'tron:2494104990/slip44:195', - ); + const expectedTransaction = { + account: 'test-account-id', + type: 'send', + id: '8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b', + from: [ + { + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + asset: { + amount: '0.01', + unit: 'TRX', + type: 'tron:2494104990/slip44:195', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + asset: { + amount: '0.01', + unit: 'TRX', + type: 'tron:2494104990/slip44:195', + fungible: true, + }, + }, + ], + chain: 'tron:2494104990', + status: 'confirmed', + timestamp: 1756914747000, + events: [ + { + status: 'confirmed', + timestamp: 1756914747000, + }, + ], + fees: [ + { + asset: { + amount: '266000', + unit: 'TRX', + type: 'tron:2494104990/slip44:195', + fungible: true, + }, + type: 'base', + }, + ], + }; + + expect(result).toStrictEqual(expectedTransaction); }); }); }); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts index 90533b5a..044e011f 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts @@ -15,8 +15,8 @@ export class TransactionMapper { /** * Calculate fees for Tron transactions including Energy and Bandwidth as separate assets. * - * @param transactionInfo The raw transaction info. * @param network The network configuration. + * @param transactionInfo The raw transaction info. * @returns Array of fee objects. */ static #calculateTronFees( @@ -36,15 +36,11 @@ export class TransactionMapper { (total, result) => total + (result.fee || 0), 0, ); - const netFeeTotal = - transactionFee + - Number(transactionInfo.net_fee) + - Number(transactionInfo.energy_fee); const setFeeIfPresent = ( amount: number, asset: { id: CaipAssetType; symbol: string }, - ) => { + ): void => { if (amount > 0) { fees.push({ type: 'base', @@ -357,6 +353,7 @@ export class TransactionMapper { */ // ASSUME: Only use the first contract to classify the transaction type. + const firstContract = trongridTransaction.raw_data.contract?.[0]; const contractType = firstContract?.type; diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts index dd3dda38..a9f7e6f0 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts @@ -191,7 +191,7 @@ describe('TransactionsService', () => { ); console.log('Empty API response result:', result); - expect(result).toEqual([]); + expect(result).toStrictEqual([]); expect(true).toBe(true); }); @@ -316,7 +316,7 @@ describe('TransactionsService', () => { const result = await transactionsService.findByAccounts([]); console.log('Empty accounts result:', result); - expect(result).toEqual([]); + expect(result).toStrictEqual([]); expect(mockTransactionsRepository.findByAccountId).not.toHaveBeenCalled(); expect(true).toBe(true); }); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/contract-info.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/contract-info.json index c1694ac3..2e71340f 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/contract-info.json +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/contract-info.json @@ -1,51 +1,51 @@ { - "data": [ - { - "transaction_id": "35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa", - "token_info": { - "symbol": "USDT", - "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "decimals": 6, - "name": "Tether USD" - }, - "block_timestamp": 1757590707000, - "from": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", - "to": "TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB", - "type": "Transfer", - "value": "10000" - }, - { - "transaction_id": "beb6312754bed1de71c4087d8f0ab078be4f8b7d25b97a6fed644a1e274c51dc", - "token_info": { - "symbol": "USDDOLD", - "address": "TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn", - "decimals": 18, - "name": "Decentralized USDOLD" - }, - "block_timestamp": 1756119357000, - "from": "TMxxHG5PRVakKwNCvTWDb73gPwXvkZAhpm", - "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", - "type": "Transfer", - "value": "1052192663968474700" - }, - { - "transaction_id": "036fd19d19626c2be4010619a365ca8d1e15f6b615585170500c7b25f1932686", - "token_info": { - "symbol": "USDT", - "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "decimals": 6, - "name": "Tether USD" - }, - "block_timestamp": 1755862641000, - "from": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", - "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", - "type": "Transfer", - "value": "1067519" - } - ], - "success": true, - "meta": { - "at": 1757687703291, - "page_size": 3 + "data": [ + { + "transaction_id": "35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa", + "token_info": { + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + "name": "Tether USD" + }, + "block_timestamp": 1757590707000, + "from": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "to": "TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB", + "type": "Transfer", + "value": "10000" + }, + { + "transaction_id": "beb6312754bed1de71c4087d8f0ab078be4f8b7d25b97a6fed644a1e274c51dc", + "token_info": { + "symbol": "USDDOLD", + "address": "TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn", + "decimals": 18, + "name": "Decentralized USDOLD" + }, + "block_timestamp": 1756119357000, + "from": "TMxxHG5PRVakKwNCvTWDb73gPwXvkZAhpm", + "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "type": "Transfer", + "value": "1052192663968474700" + }, + { + "transaction_id": "036fd19d19626c2be4010619a365ca8d1e15f6b615585170500c7b25f1932686", + "token_info": { + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + "name": "Tether USD" + }, + "block_timestamp": 1755862641000, + "from": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", + "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "type": "Transfer", + "value": "1067519" } -} \ No newline at end of file + ], + "success": true, + "meta": { + "at": 1757687703291, + "page_size": 3 + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/native-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/native-transfer.json index f3151aa8..db1ee0f0 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/native-transfer.json +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/native-transfer.json @@ -1,40 +1,40 @@ { - "ret": [ - { - "contractRet": "SUCCESS", - "fee": 266000 - } + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 266000 + } + ], + "signature": [ + "128b88278e273e5b1f89c6f0e1047af67406206bc5c848229d072dede4303ba90ccc25393003fc4346d7aaa8c0d44570d3f86510fa74e6435024484e5fec77de1b" + ], + "txID": "8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b", + "net_usage": 0, + "raw_data_hex": "0a02cb1d2208d3fc65a6fb0f782d40e8b2a18291335a66080112620a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412310a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff41215412efffc7686e54ab669a1cdb1e2cc17cf4b4eca9618904e7088de9d829133", + "net_fee": 266000, + "energy_usage": 0, + "blockNumber": 75418399, + "block_timestamp": 1756914747000, + "energy_fee": 0, + "energy_usage_total": 0, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 10000, + "owner_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4", + "to_address": "412efffc7686e54ab669a1cdb1e2cc17cf4b4eca96" + }, + "type_url": "type.googleapis.com/protocol.TransferContract" + }, + "type": "TransferContract" + } ], - "signature": [ - "128b88278e273e5b1f89c6f0e1047af67406206bc5c848229d072dede4303ba90ccc25393003fc4346d7aaa8c0d44570d3f86510fa74e6435024484e5fec77de1b" - ], - "txID": "8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b", - "net_usage": 0, - "raw_data_hex": "0a02cb1d2208d3fc65a6fb0f782d40e8b2a18291335a66080112620a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412310a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff41215412efffc7686e54ab669a1cdb1e2cc17cf4b4eca9618904e7088de9d829133", - "net_fee": 266000, - "energy_usage": 0, - "blockNumber": 75418399, - "block_timestamp": 1756914747000, - "energy_fee": 0, - "energy_usage_total": 0, - "raw_data": { - "contract": [ - { - "parameter": { - "value": { - "amount": 10000, - "owner_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4", - "to_address": "412efffc7686e54ab669a1cdb1e2cc17cf4b4eca96" - }, - "type_url": "type.googleapis.com/protocol.TransferContract" - }, - "type": "TransferContract" - } - ], - "ref_block_bytes": "cb1d", - "ref_block_hash": "d3fc65a6fb0f782d", - "expiration": 1756914801000, - "timestamp": 1756914741000 - }, - "internal_transactions": [] -} \ No newline at end of file + "ref_block_bytes": "cb1d", + "ref_block_hash": "d3fc65a6fb0f782d", + "expiration": 1756914801000, + "timestamp": 1756914741000 + }, + "internal_transactions": [] +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc10-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc10-transfer.json index 47939430..350240c7 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc10-transfer.json +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc10-transfer.json @@ -1,41 +1,41 @@ { - "ret": [ - { - "contractRet": "SUCCESS", - "fee": 281000 - } + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 281000 + } + ], + "signature": [ + "1910a1c31507d603df8de792ebfe30cf29e988fdb7a964877a296d75b998c68609bb306c95f5f28a7fc6295fb39548423dc7a213ff97a1cce63022b9eed9541900" + ], + "txID": "d8fc96d5b81fe600e055741e27135e22d5ae42584c9056758f797b1a20328818", + "net_usage": 0, + "raw_data_hex": "0a0291bc22087bb3675595654f0e40d0839fed90335a75080212710a32747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e736665724173736574436f6e7472616374123b0a07313030323030301215413986cff58bc3066e62f43f2e32f603d026a437261a15416902a7371d035fed4e85fa9642e689c657634b7520b0931e70f0ae9bed9033", + "net_fee": 281000, + "energy_usage": 0, + "blockNumber": 75403713, + "block_timestamp": 1756870677000, + "energy_fee": 0, + "energy_usage_total": 0, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 494000, + "asset_name": "1002000", + "owner_address": "413986cff58bc3066e62f43f2e32f603d026a43726", + "to_address": "416902a7371d035fed4e85fa9642e689c657634b75" + }, + "type_url": "type.googleapis.com/protocol.TransferAssetContract" + }, + "type": "TransferAssetContract" + } ], - "signature": [ - "1910a1c31507d603df8de792ebfe30cf29e988fdb7a964877a296d75b998c68609bb306c95f5f28a7fc6295fb39548423dc7a213ff97a1cce63022b9eed9541900" - ], - "txID": "d8fc96d5b81fe600e055741e27135e22d5ae42584c9056758f797b1a20328818", - "net_usage": 0, - "raw_data_hex": "0a0291bc22087bb3675595654f0e40d0839fed90335a75080212710a32747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e736665724173736574436f6e7472616374123b0a07313030323030301215413986cff58bc3066e62f43f2e32f603d026a437261a15416902a7371d035fed4e85fa9642e689c657634b7520b0931e70f0ae9bed9033", - "net_fee": 281000, - "energy_usage": 0, - "blockNumber": 75403713, - "block_timestamp": 1756870677000, - "energy_fee": 0, - "energy_usage_total": 0, - "raw_data": { - "contract": [ - { - "parameter": { - "value": { - "amount": 494000, - "asset_name": "1002000", - "owner_address": "413986cff58bc3066e62f43f2e32f603d026a43726", - "to_address": "416902a7371d035fed4e85fa9642e689c657634b75" - }, - "type_url": "type.googleapis.com/protocol.TransferAssetContract" - }, - "type": "TransferAssetContract" - } - ], - "ref_block_bytes": "91bc", - "ref_block_hash": "7bb3675595654f0e", - "expiration": 1756870722000, - "timestamp": 1756870662000 - }, - "internal_transactions": [] -} \ No newline at end of file + "ref_block_bytes": "91bc", + "ref_block_hash": "7bb3675595654f0e", + "expiration": 1756870722000, + "timestamp": 1756870662000 + }, + "internal_transactions": [] +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc20-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc20-transfer.json index a66e6875..3b70e2bb 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc20-transfer.json +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trc20-transfer.json @@ -1,41 +1,41 @@ { - "ret": [ - { - "contractRet": "SUCCESS", - "fee": 12987800 - } + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 12987800 + } + ], + "signature": [ + "0d1a94c67125916037fe92b7f266cd595100803f5ceb21838c43f3e97a5346ee3798ee350cca88f3e04212baf984dc638a2c965fb42e31301d3556ebf04630101c" + ], + "txID": "35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa", + "net_usage": 345, + "raw_data_hex": "0a023b08220868ca0d471db52d6f40e0f3cac493335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff4121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244a9059cbb0000000000000000000000002efffc7686e54ab669a1cdb1e2cc17cf4b4eca96000000000000000000000000000000000000000000000000000000000000271070809fc7c493339001c0f4a46b", + "net_fee": 0, + "energy_usage": 407, + "blockNumber": 75643657, + "block_timestamp": 1757590707000, + "energy_fee": 12987800, + "energy_usage_total": 130285, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000002efffc7686e54ab669a1cdb1e2cc17cf4b4eca960000000000000000000000000000000000000000000000000000000000002710", + "owner_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4", + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } ], - "signature": [ - "0d1a94c67125916037fe92b7f266cd595100803f5ceb21838c43f3e97a5346ee3798ee350cca88f3e04212baf984dc638a2c965fb42e31301d3556ebf04630101c" - ], - "txID": "35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa", - "net_usage": 345, - "raw_data_hex": "0a023b08220868ca0d471db52d6f40e0f3cac493335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff4121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244a9059cbb0000000000000000000000002efffc7686e54ab669a1cdb1e2cc17cf4b4eca96000000000000000000000000000000000000000000000000000000000000271070809fc7c493339001c0f4a46b", - "net_fee": 0, - "energy_usage": 407, - "blockNumber": 75643657, - "block_timestamp": 1757590707000, - "energy_fee": 12987800, - "energy_usage_total": 130285, - "raw_data": { - "contract": [ - { - "parameter": { - "value": { - "data": "a9059cbb0000000000000000000000002efffc7686e54ab669a1cdb1e2cc17cf4b4eca960000000000000000000000000000000000000000000000000000000000002710", - "owner_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4", - "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" - }, - "type_url": "type.googleapis.com/protocol.TriggerSmartContract" - }, - "type": "TriggerSmartContract" - } - ], - "ref_block_bytes": "3b08", - "ref_block_hash": "68ca0d471db52d6f", - "expiration": 1757590764000, - "fee_limit": 225000000, - "timestamp": 1757590704000 - }, - "internal_transactions": [] -} \ No newline at end of file + "ref_block_bytes": "3b08", + "ref_block_hash": "68ca0d471db52d6f", + "expiration": 1757590764000, + "fee_limit": 225000000, + "timestamp": 1757590704000 + }, + "internal_transactions": [] +} From 63434bcff2ead4a6650fe6f501eab709051427c3 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 22 Sep 2025 16:04:03 +0100 Subject: [PATCH 027/238] feat: send staked TRX as assets (#29) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../tron-wallet-snap/src/entities/assets.ts | 52 +++--- .../src/services/assets/AssetsService.ts | 148 ++++++++++++++++-- .../src/services/assets/types.ts | 12 +- 4 files changed, 167 insertions(+), 47 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 7c6bbe8a..5a724e59 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "HvFu/qDLERf0AdFdzMSqByw7iGOjOEnZMg+ih8Eh2uE=", + "shasum": "7OoyJmGF4HGR1xrUC6JaNjy02ohNqtLdzFDwIfwqd1I=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/entities/assets.ts b/merged-packages/tron-wallet-snap/src/entities/assets.ts index 8d9047c9..64b9bacd 100644 --- a/merged-packages/tron-wallet-snap/src/entities/assets.ts +++ b/merged-packages/tron-wallet-snap/src/entities/assets.ts @@ -5,49 +5,39 @@ import type { TokenCaipAssetType, } from '../services/assets/types'; -export type NativeAsset = { - assetType: NativeCaipAssetType; +type BaseAsset = { + assetType: string; keyringAccountId: string; network: Network; - address: string; symbol: string; decimals: number; rawAmount: string; // Without decimals uiAmount: string; // With decimals }; -export type TokenAsset = { - assetType: TokenCaipAssetType; // Using the mint - keyringAccountId: string; - network: Network; - mint: string; - pubkey: string; - symbol: string; - decimals: number; - rawAmount: string; // Without decimals - uiAmount: string; // With decimals +export type NativeAsset = BaseAsset & { + assetType: NativeCaipAssetType; }; -export type NftAsset = { - assetType: NftCaipAssetType; - keyringAccountId: string; - network: Network; - mint: string; - pubkey: string; - symbol: string; - rawAmount: string; // Without decimals - uiAmount: string; // With decimals +export type StakedAsset = BaseAsset & { + assetType: `${Network}/slip44:195-staked-for-${'energy' | 'bandwidth'}`; }; -export type ResourceAsset = { +export type ResourceAsset = BaseAsset & { assetType: `${Network}/slip44:energy` | `${Network}/slip44:bandwidth`; - keyringAccountId: string; - network: Network; - mint: string; - pubkey: string; - symbol: string; - rawAmount: string; // Without decimals - uiAmount: string; // With decimals }; -export type AssetEntity = NativeAsset | TokenAsset | NftAsset | ResourceAsset; +export type TokenAsset = BaseAsset & { + assetType: TokenCaipAssetType; // Using the mint +}; + +export type NftAsset = BaseAsset & { + assetType: NftCaipAssetType; +}; + +export type AssetEntity = + | NativeAsset + | StakedAsset + | TokenAsset + | NftAsset + | ResourceAsset; diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 1e416d12..d6092b5a 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -22,6 +22,7 @@ import type { NftCaipAssetType, TokenCaipAssetType, ResourceCaipAssetType, + StakedCaipAssetType, } from './types'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { FiatTicker, SpotPrice } from '../../clients/price-api/types'; @@ -99,6 +100,11 @@ export class AssetsService { scope, tronAccountInfo, }); + const stakedNativeAssets = this.#extractStakedNativeAssets({ + account, + scope, + tronAccountInfo, + }); const energyAsset = this.#extractEnergy({ account, scope, @@ -122,6 +128,7 @@ export class AssetsService { const assetTypes = [ nativeAsset.assetType, + ...stakedNativeAssets.map((stakedAsset) => stakedAsset.assetType), energyAsset.assetType, bandwidthAsset.assetType, ...trc10Assets.map((tokenAsset) => tokenAsset.assetType), @@ -131,6 +138,7 @@ export class AssetsService { const assets = [ nativeAsset, + ...stakedNativeAssets, energyAsset, bandwidthAsset, ...trc10Assets, @@ -179,7 +187,6 @@ export class AssetsService { assetType: `${scope}/slip44:195` as NativeCaipAssetType, keyringAccountId: account.id, network: scope, - address: account.address, symbol: 'TRX', decimals: 6, rawAmount: tronAccountInfo.balance.toString(), @@ -191,6 +198,68 @@ export class AssetsService { return asset; } + #extractStakedNativeAssets({ + account, + scope, + tronAccountInfo, + }: { + account: KeyringAccount; + scope: Network; + tronAccountInfo: TronAccount; + }): AssetEntity[] { + const assets: AssetEntity[] = []; + + // Calculate staked amounts by type + let stakedEnergyAmount = 0; + let stakedBandwidthAmount = 0; + + tronAccountInfo.frozenV2?.forEach((frozen) => { + const amount = frozen.amount ?? 0; + + if (frozen.type === 'ENERGY') { + stakedEnergyAmount += amount; + } else if (!frozen.type) { + // Item without type is for bandwidth + stakedBandwidthAmount += amount; + } + }); + + // Create staked energy asset if there's any staked energy + if (stakedEnergyAmount > 0) { + const stakedEnergyAsset: AssetEntity = { + assetType: `${scope}/slip44:195-staked-for-energy` as const, + keyringAccountId: account.id, + network: scope, + symbol: 'sTRX-ENERGY', + decimals: 6, + rawAmount: stakedEnergyAmount.toString(), + uiAmount: new BigNumber(stakedEnergyAmount) + .dividedBy(10 ** 6) + .toString(), + }; + assets.push(stakedEnergyAsset); + } + + // Create staked bandwidth asset if there's any staked bandwidth + if (stakedBandwidthAmount > 0) { + const stakedBandwidthAsset: AssetEntity = { + assetType: + `${scope}/slip44:195-staked-for-bandwidth` as NativeCaipAssetType, + keyringAccountId: account.id, + network: scope, + symbol: 'sTRX-BANDWIDTH', + decimals: 6, + rawAmount: stakedBandwidthAmount.toString(), + uiAmount: new BigNumber(stakedBandwidthAmount) + .dividedBy(10 ** 6) + .toString(), + }; + assets.push(stakedBandwidthAsset); + } + + return assets; + } + #extractEnergy({ account, scope, @@ -206,9 +275,8 @@ export class AssetsService { assetType: Networks[scope].energy.id, keyringAccountId: account.id, network: scope, - mint: '', - pubkey: '', symbol: 'ENERGY', + decimals: 0, rawAmount: energy.toString(), uiAmount: energy.toString(), }; @@ -228,13 +296,12 @@ export class AssetsService { const bandwidth = tronAccountResources.freeNetLimit + tronAccountResources.NetLimit; - const asset: AssetEntity = { + const asset: ResourceAsset = { assetType: Networks[scope].bandwidth.id, keyringAccountId: account.id, network: scope, - mint: '', - pubkey: '', symbol: 'BANDWIDTH', + decimals: 0, rawAmount: bandwidth.toString(), uiAmount: bandwidth.toString(), }; @@ -260,8 +327,6 @@ export class AssetsService { assetType: `${scope}/trc10:${address}` as TokenCaipAssetType, keyringAccountId: account.id, network: scope, - mint: address, - pubkey: address, symbol: '', decimals: 0, rawAmount: balance, @@ -291,8 +356,6 @@ export class AssetsService { assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, keyringAccountId: account.id, network: scope, - mint: address, - pubkey: address, symbol: '', decimals: 0, rawAmount: balance, @@ -311,6 +374,7 @@ export class AssetsService { const { nativeAssetTypes, + stakedNativeAssetTypes, energyAssetTypes, bandwidthAssetTypes, tokenTrc10AssetTypes, @@ -319,12 +383,14 @@ export class AssetsService { const [ nativeTokensMetadata, + stakedTokensMetadata, energyTokensMetadata, bandwidthTokensMetadata, trc10TokensMetadata, trc20TokensMetadata, ] = await Promise.all([ this.#getNativeTokensMetadata(nativeAssetTypes), + this.#getStakedTokensMetadata(stakedNativeAssetTypes), this.#getEnergyMetadata(energyAssetTypes), this.#getBandwidthMetadata(bandwidthAssetTypes), this.#getTRC10TokensMetadata(tokenTrc10AssetTypes), @@ -333,6 +399,7 @@ export class AssetsService { const result = { ...nativeTokensMetadata, + ...stakedTokensMetadata, ...energyTokensMetadata, ...bandwidthTokensMetadata, ...trc10TokensMetadata, @@ -346,6 +413,7 @@ export class AssetsService { #splitAssetsByType(assetTypes: CaipAssetType[]): { nativeAssetTypes: NativeCaipAssetType[]; + stakedNativeAssetTypes: StakedCaipAssetType[]; energyAssetTypes: ResourceCaipAssetType[]; bandwidthAssetTypes: ResourceCaipAssetType[]; tokenTrc10AssetTypes: TokenCaipAssetType[]; @@ -355,6 +423,9 @@ export class AssetsService { const nativeAssetTypes = assetTypes.filter((assetType) => assetType.endsWith('/slip44:195'), ) as NativeCaipAssetType[]; + const stakedNativeAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/slip44:195-staked-for-'), + ) as StakedCaipAssetType[]; const energyAssetTypes = assetTypes.filter((assetType) => assetType.endsWith('/slip44:energy'), ) as ResourceCaipAssetType[]; @@ -373,6 +444,7 @@ export class AssetsService { return { nativeAssetTypes, + stakedNativeAssetTypes, energyAssetTypes, bandwidthAssetTypes, tokenTrc10AssetTypes, @@ -411,6 +483,58 @@ export class AssetsService { return nativeTokensMetadata; } + #getStakedTokensMetadata( + assetTypes: StakedCaipAssetType[], + ): Record { + // Can either be Staked for Energy or Staked for Bandwidth + const stakedTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + const isForEnergy = assetType.endsWith('staked-for-energy'); + + if (isForEnergy) { + stakedTokensMetadata[assetType] = { + name: 'Staked for Energy', + symbol: 'sTRX-ENERGY', + fungible: true as const, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'sTRX-ENERGY', + symbol: 'sTRX-ENERGY', + decimals: 6, + }, + ], + }; + } + + const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); + + if (isForBandwdidth) { + stakedTokensMetadata[assetType] = { + name: 'Staked for Bandwidth', + symbol: 'sTRX-BANDWIDTH', + fungible: true as const, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'sTRX-BANDWIDTH', + symbol: 'sTRX-BANDWIDTH', + decimals: 6, + }, + ], + }; + } + } + + return stakedTokensMetadata; + } + #getEnergyMetadata( assetTypes: ResourceCaipAssetType[], ): Record { @@ -420,12 +544,10 @@ export class AssetsService { > = {}; for (const assetType of assetTypes) { - // const { chainId } = parseCaipAssetType(assetType); energyTokensMetadata[assetType] = { name: 'Energy', symbol: 'ENERGY', fungible: true as const, - // iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', units: [ @@ -450,12 +572,10 @@ export class AssetsService { > = {}; for (const assetType of assetTypes) { - // const { chainId } = parseCaipAssetType(assetType); bandwidthTokensMetadata[assetType] = { name: 'Bandwidth', symbol: 'BANDWIDTH', fungible: true as const, - // iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', units: [ diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts index b720fd12..afcf0d9f 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -2,8 +2,10 @@ import type { TrxScope } from '@metamask/keyring-api'; import { pattern, string } from '@metamask/superstruct'; export type NativeCaipAssetType = `${TrxScope}/slip44:195`; +export type StakedCaipAssetType = + `${TrxScope}/slip44:195-staked-for-${'energy' | 'bandwidth'}`; export type ResourceCaipAssetType = - `${TrxScope}/slip44:${'energy' | 'bandwidth'}`; + `${TrxScope}/slip44:195-${'energy' | 'bandwidth'}`; export type TokenCaipAssetType = `${TrxScope}/${'trc10' | 'trc20'}:${string}`; export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; @@ -15,6 +17,14 @@ export const NativeCaipAssetTypeStruct = pattern( /^tron:(mainnet|nile|shasta)\/slip44:195$/u, ); +/** + * Validates a TRON native CAIP-19 ID (e.g., "tron:mainnet/slip44:195") + */ +export const StakedCaipAssetTypeStruct = pattern( + string(), + /^tron:(mainnet|nile|shasta)\/slip44:195-staked-for-(energy|bandwidth)$/u, +); + /** * Validates a TRON native CAIP-19 ID for resources (e.g., "tron:mainnet/energy" or "tron:mainnet/bandwidth") */ From 44f2672f14be67e935eda66dacd003aeae113545 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 25 Sep 2025 17:03:36 +0100 Subject: [PATCH 028/238] feat: implement "Unified Non-EVM Send" spec (#28) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/tronweb/TronWebFactory.ts | 50 ++++ .../tron-wallet-snap/src/context.ts | 37 ++- .../tron-wallet-snap/src/handlers/assets.ts | 15 +- .../handlers/clientRequest/clientRequest.ts | 246 +++++++++++++++ .../src/handlers/clientRequest/types.ts | 14 + .../src/handlers/clientRequest/validation.ts | 101 +++++++ merged-packages/tron-wallet-snap/src/index.ts | 5 + .../src/services/connection/Connection.ts | 50 ---- .../src/services/send/SendService.ts | 280 ++++++++++++++++++ .../src/services/send/types.ts | 5 + .../src/services/wallet/WalletService.ts | 34 --- .../src/validation/structs.ts | 32 +- 13 files changed, 767 insertions(+), 104 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts delete mode 100644 merged-packages/tron-wallet-snap/src/services/connection/Connection.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/send/SendService.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/send/types.ts delete mode 100644 merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 5a724e59..b3e56748 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "7OoyJmGF4HGR1xrUC6JaNjy02ohNqtLdzFDwIfwqd1I=", + "shasum": "36nyvyI0DNUusuf72wAw0Ot4ITYntf2H0qj1p1Tzc1U=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts new file mode 100644 index 00000000..4b389be6 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts @@ -0,0 +1,50 @@ +import { TronWeb } from 'tronweb'; + +import type { Network } from '../../constants'; +import type { ConfigProvider } from '../../services/config'; + +export class TronWebFactory { + readonly #configProvider: ConfigProvider; + + constructor({ configProvider }: { configProvider: ConfigProvider }) { + this.#configProvider = configProvider; + } + + /** + * Create a TronWeb client instance for a specific network + * This creates a fresh instance each time, avoiding state management issues + * + * @param network - The network to create the client for + * @param privateKey - Optional private key to configure the client with + * @returns TronWeb instance configured for the specified network + * + * @example + * // With private key for signing transactions + * const tronWeb = factory.createClient(Network.Mainnet, privateKey); + * const transaction = await tronWeb.transactionBuilder.sendTrx(to, amount); + * const signedTx = await tronWeb.trx.sign(transaction); + * + * // Without private key for read-only operations + * const tronWeb = factory.createClient(Network.Mainnet); + * const balance = await tronWeb.trx.getBalance(address); + */ + createClient(network: Network, privateKey?: string): TronWeb { + const config = this.#configProvider.get(); + const { apiKey, baseUrls } = config.trongridApi; + + const fullHost = baseUrls[network]; + if (!fullHost) { + throw new Error(`No configuration found for network: ${network}`); + } + + const headers = apiKey ? { 'TRON-PRO-API-KEY': apiKey } : {}; + + const tronWebConfig = { + fullHost, + headers, + ...(privateKey && { privateKey }), + }; + + return new TronWeb(tronWebConfig); + } +} diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index f32f0c53..8cdd08f2 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -3,7 +3,9 @@ import { PriceApiClient } from './clients/price-api/PriceApiClient'; import { SnapClient } from './clients/snap/SnapClient'; import { TronHttpClient } from './clients/tron-http/TronHttpClient'; import { TrongridApiClient } from './clients/trongrid/TrongridApiClient'; +import { TronWebFactory } from './clients/tronweb/TronWebFactory'; import { AssetsHandler } from './handlers/assets'; +import { ClientRequestHandler } from './handlers/clientRequest/clientRequest'; import { CronHandler } from './handlers/cronjob'; import { KeyringHandler } from './handlers/keyring'; import { LifecycleHandler } from './handlers/lifecycle'; @@ -14,11 +16,11 @@ import { AccountsService } from './services/accounts/AccountsService'; import { AssetsRepository } from './services/assets/AssetsRepository'; import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; +import { SendService } from './services/send/SendService'; import type { UnencryptedStateValue } from './services/state/State'; import { State } from './services/state/State'; import { TransactionsRepository } from './services/transactions/TransactionsRepository'; import { TransactionsService } from './services/transactions/TransactionsService'; -import { WalletService } from './services/wallet/WalletService'; import logger, { noOpLogger } from './utils/logger'; /** @@ -27,7 +29,7 @@ import logger, { noOpLogger } from './utils/logger'; * Dependency injection order: * 1. Core services (ConfigProvider, State, Connection) * 2. Repositories (AssetsRepository, TransactionsRepository, AccountsRepository) - * 3. Business services (AssetsService, TransactionsService, AccountsService, WalletService) + * 3. Business services (AssetsService, TransactionsService, AccountsService) * 4. Handlers (AssetsHandler, CronHandler, KeyringHandler, RpcHandler, UserInputHandler) */ export const configProvider = new ConfigProvider(); @@ -56,12 +58,15 @@ const trongridApiClient = new TrongridApiClient({ const tronHttpClient = new TronHttpClient({ configProvider, }); +const tronWebFactory = new TronWebFactory({ + configProvider, +}); // Cache for PriceApiClient const priceCache = new InMemoryCache(noOpLogger); const priceApiClient = new PriceApiClient(configProvider, priceCache); -// Business Services - depend on Repositories, State, Connection, and other Services +// Business Services - depend on Repositories, State and other Services const assetsService = new AssetsService({ logger, state, @@ -77,11 +82,6 @@ const transactionsService = new TransactionsService({ trongridApiClient, }); -const walletService = new WalletService({ - logger, - state, -}); - const accountsService = new AccountsService({ logger, accountsRepository, @@ -91,6 +91,12 @@ const accountsService = new AccountsService({ transactionsService, }); +const sendService = new SendService({ + logger, + accountsService, + tronWebFactory, +}); + /** * Handlers */ @@ -98,6 +104,12 @@ const assetsHandler = new AssetsHandler({ logger, assetsService, }); +const clientRequestHandler = new ClientRequestHandler({ + logger, + accountsService, + assetsService, + sendService, +}); const cronHandler = new CronHandler({ logger, accountsService, @@ -124,15 +136,17 @@ export type SnapExecutionContext = { */ state: State; assetsService: AssetsService; - walletService: WalletService; accountsService: AccountsService; transactionsService: TransactionsService; + sendService: SendService; tronHttpClient: TronHttpClient; + tronWebFactory: TronWebFactory; /** * Handlers */ assetsHandler: AssetsHandler; cronHandler: CronHandler; + clientRequestHandler: ClientRequestHandler; lifecycleHandler: LifecycleHandler; keyringHandler: KeyringHandler; rpcHandler: RpcHandler; @@ -145,14 +159,16 @@ const snapContext: SnapExecutionContext = { */ state, assetsService, - walletService, accountsService, transactionsService, + sendService, tronHttpClient, + tronWebFactory, /** * Handlers */ assetsHandler, + clientRequestHandler, cronHandler, lifecycleHandler, keyringHandler, @@ -165,6 +181,7 @@ export { * Handlers */ assetsHandler, + clientRequestHandler, cronHandler, keyringHandler, lifecycleHandler, diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts index 53cf4c65..eeabcd7b 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/assets.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -47,12 +47,19 @@ export class AssetsHandler { const { conversions } = params; + const excludedAssetSuffixes = [ + '/slip44:energy', + '/slip44:bandwidth', + '/slip44:195-staked-for-energy', + '/slip44:195-staked-for-bandwidth', + ]; + const filteredConversions = conversions.filter( (conversion) => - !conversion.from.endsWith('/slip44:energy') && - !conversion.from.endsWith('/slip44:bandwidth') && - !conversion.to.endsWith('/slip44:energy') && - !conversion.to.endsWith('/slip44:bandwidth'), + !excludedAssetSuffixes.some((suffix) => + conversion.from.endsWith(suffix), + ) && + !excludedAssetSuffixes.some((suffix) => conversion.to.endsWith(suffix)), ); const conversionRates = diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts new file mode 100644 index 00000000..1218b907 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -0,0 +1,246 @@ +import { FeeType, TransactionStatus } from '@metamask/keyring-api'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; + +import { ClientRequestMethod, SendErrorCodes } from './types'; +import type { ComputeFeeResponse } from './validation'; +import { + ComputeFeeRequestStruct, + ComputeFeeResponseStruct, + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, + OnConfirmSendRequestStruct, +} from './validation'; +import { Networks } from '../../constants'; +import type { AccountsService } from '../../services/accounts/AccountsService'; +import type { AssetsService } from '../../services/assets/AssetsService'; +import type { SendService } from '../../services/send/SendService'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; + +export class ClientRequestHandler { + readonly #logger: ILogger; + + readonly #accountsService: AccountsService; + + readonly #assetsService: AssetsService; + + readonly #sendService: SendService; + + constructor({ + logger, + accountsService, + assetsService, + sendService, + }: { + logger: ILogger; + accountsService: AccountsService; + assetsService: AssetsService; + sendService: SendService; + }) { + this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); + this.#accountsService = accountsService; + this.#assetsService = assetsService; + this.#sendService = sendService; + } + + /** + * Handles JSON-RPC requests originating exclusively from the client - as defined in [SIP-31](https://github.com/MetaMask/SIPs/blob/main/SIPS/sip-31.md) - + * by routing them to the appropriate use case, based on the method. Some methods need to be implemented + * as part of the [Unified Non-EVM Send](https://www.notion.so/metamask-consensys/Unified-Non-EVM-Send-248f86d67d6880278445f9ad75478471) specification. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {MethodNotFoundError} If the method is not found. + * @throws {InvalidParamsError} If the params are invalid. + */ + async handle(request: JsonRpcRequest): Promise { + this.#logger.log('Handling client request', request); + + const { method } = request; + + switch (method as ClientRequestMethod) { + case ClientRequestMethod.ConfirmSend: + return this.#handleConfirmSend(request); + case ClientRequestMethod.ComputeFee: + return this.#handleComputeFee(request); + case ClientRequestMethod.OnAddressInput: + return this.#handleOnAddressInput(request); + case ClientRequestMethod.OnAmountInput: + return this.#handleOnAmountInput(request); + default: + throw new MethodNotFoundError() as Error; + } + } + + /** + * Handles the confirmation and sending of a transaction. + * + * @param request - The JSON-RPC request containing transaction details. + * @returns The transaction result with hash and status. + */ + async #handleConfirmSend(request: JsonRpcRequest): Promise { + try { + assert(request, OnConfirmSendRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { fromAccountId, toAddress, amount, assetId } = request.params; + + const transaction = await this.#sendService.sendAsset({ + fromAccountId, + toAddress, + amount: BigNumber(amount).toNumber(), + assetId, + }); + + return { + transactionId: transaction.txId, + status: TransactionStatus.Submitted, + }; + } + + /** + * Handles the input of an address. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleOnAddressInput(request: JsonRpcRequest): Promise { + try { + assert(request, OnAddressInputRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + // If we reach this point, the address is valid (validated by TronAddressStruct) + return { + valid: true, + errors: [], + }; + } + + /** + * Handles amount input validation and balance checking. + * + * @param request - The JSON-RPC request containing amount and asset details. + * @returns Validation result with balance and sufficiency status. + */ + async #handleOnAmountInput(request: JsonRpcRequest): Promise { + try { + assert(request, OnAmountInputRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + /** + * Check if the user has enough of the asset + */ + const { accountId, assetId, value } = request.params; + + const account = await this.#accountsService.findById(accountId); + + /** + * The account does not exist... + */ + if (!account) { + return { + valid: false, + errors: [SendErrorCodes.Invalid], + }; + } + + const accountAssets = + await this.#assetsService.getByKeyringAccountId(accountId); + const asset = accountAssets.find( + (assetItem) => assetItem.assetType === assetId, + ); + + /** + * The account does not have this asset... + */ + if (!asset) { + return { + valid: false, + errors: [SendErrorCodes.Invalid], + }; + } + + const balance = BigNumber(asset.uiAmount); + const amount = BigNumber(value); + + if (amount.isGreaterThan(balance)) { + return { + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }; + } + + return { + valid: true, + errors: [], + }; + } + + /** + * Handles the computation of a fee for a transaction. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleComputeFee(request: JsonRpcRequest): Promise { + try { + assert(request, ComputeFeeRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { + params: { scope }, + } = request; + + // TODO: Implement actual fee computation logic here. + const { baseFee, priorityFee } = { baseFee: '0', priorityFee: '0' }; + + const units = Networks[scope].nativeToken.symbol; + const type = Networks[scope].nativeToken.id; + + const result: ComputeFeeResponse = [ + { + type: FeeType.Base, + asset: { + unit: units, + type, + amount: baseFee, + fungible: true, + }, + }, + { + type: FeeType.Priority, + asset: { + unit: units, + type, + amount: priorityFee, + fungible: true, + }, + }, + ]; + + assert(result, ComputeFeeResponseStruct); + + return result; + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts new file mode 100644 index 00000000..513d8bd1 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts @@ -0,0 +1,14 @@ +export enum ClientRequestMethod { + ConfirmSend = 'confirmSend', + ComputeFee = 'computeFee', + OnAddressInput = 'onAddressInput', + OnAmountInput = 'onAmountInput', +} + +export enum SendErrorCodes { + // eslint-disable-next-line @typescript-eslint/no-shadow + Required = 'Required', + Invalid = 'Invalid', + InsufficientBalanceToCoverFee = 'InsufficientBalanceToCoverFee', + InsufficientBalance = 'InsufficientBalance', +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts new file mode 100644 index 00000000..5d3f0416 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -0,0 +1,101 @@ +import { AssetStruct, FeeType } from '@metamask/keyring-api'; +import { literal } from '@metamask/snaps-sdk'; +import type { Infer } from '@metamask/superstruct'; +import { array, boolean, enums, object } from '@metamask/superstruct'; +import { + CaipAssetTypeStruct, + JsonRpcIdStruct, + JsonRpcVersionStruct, +} from '@metamask/utils'; + +import { ClientRequestMethod, SendErrorCodes } from './types'; +import { + Base64Struct, + PositiveNumberStringStruct, + ScopeStringStruct, + TronAddressStruct, + UuidStruct, +} from '../../validation/structs'; + +/** + * onConfirmSend request/response validation. + */ +export const OnConfirmSendRequestParamsStruct = object({ + fromAccountId: UuidStruct, + toAddress: TronAddressStruct, + amount: PositiveNumberStringStruct, + assetId: CaipAssetTypeStruct, +}); + +export const OnConfirmSendRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ConfirmSend), + params: OnConfirmSendRequestParamsStruct, +}); + +/** + * onAddressInput request/response validation. + */ +export const OnAddressInputRequestParamsStruct = object({ + value: TronAddressStruct, +}); + +export const OnAddressInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnAddressInput), + params: OnAddressInputRequestParamsStruct, +}); + +/** + * onAmountInput request/response validation. + */ +export const OnAmountInputRequestParamsStruct = object({ + value: PositiveNumberStringStruct, + accountId: UuidStruct, + assetId: CaipAssetTypeStruct, +}); + +export const OnAmountInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnAmountInput), + params: OnAmountInputRequestParamsStruct, +}); + +export const ValidationResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: enums(Object.values(SendErrorCodes)), + }), + ), +}); + +export type ValidationResponse = Infer; + +/** + * computeFee request/response validation. + */ +export const ComputeFeeRequestParamsStruct = object({ + transaction: Base64Struct, + accountId: UuidStruct, + scope: ScopeStringStruct, +}); + +export const ComputeFeeRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ComputeFee), + params: ComputeFeeRequestParamsStruct, +}); + +export const ComputeFeeResponseStruct = array( + object({ + type: enums(Object.values(FeeType)), + asset: AssetStruct, + }), +); + +export type ComputeFeeResponse = Infer; diff --git a/merged-packages/tron-wallet-snap/src/index.ts b/merged-packages/tron-wallet-snap/src/index.ts index 507d9702..2be0b8e6 100644 --- a/merged-packages/tron-wallet-snap/src/index.ts +++ b/merged-packages/tron-wallet-snap/src/index.ts @@ -4,6 +4,7 @@ import type { OnAssetsConversionHandler, OnAssetsLookupHandler, OnAssetsMarketDataHandler, + OnClientRequestHandler, OnCronjobHandler, OnKeyringRequestHandler, OnRpcRequestHandler, @@ -12,6 +13,7 @@ import type { import { assetsHandler, + clientRequestHandler, cronHandler, keyringHandler, lifecycleHandler, @@ -36,6 +38,9 @@ export const onAssetsLookup: OnAssetsLookupHandler = async (args) => export const onAssetsMarketData: OnAssetsMarketDataHandler = async (args) => assetsHandler.onAssetsMarketData(args); +export const onClientRequest: OnClientRequestHandler = async ({ request }) => + clientRequestHandler.handle(request); + export const onCronjob: OnCronjobHandler = async ({ request }) => cronHandler.handle(request); diff --git a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts b/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts deleted file mode 100644 index 22261f7d..00000000 --- a/merged-packages/tron-wallet-snap/src/services/connection/Connection.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { assert } from '@metamask/superstruct'; - -import type { Network } from '../../constants'; -import { NetworkStruct } from '../../validation/structs'; -import type { ConfigProvider, NetworkConfig } from '../config'; - -/** - * Simplified connection service that can be extended if needed - * Most functionality has been moved to specialized HTTP clients - */ -export class Connection { - readonly #configProvider: ConfigProvider; - - constructor(configProvider: ConfigProvider) { - this.#configProvider = configProvider; - } - - /** - * Get network configuration for a specific network - * - * @param network - The network to get configuration for - * @returns Network configuration - */ - getNetworkConfig(network: Network): NetworkConfig { - assert(network, NetworkStruct); - return this.#configProvider.getNetworkBy('caip2Id', network); - } - - /** - * Get RPC URLs for a specific network - * - * @param network - The network to get RPC URLs for - * @returns Array of RPC URLs - */ - getRpcUrls(network: Network): string[] { - const config = this.getNetworkConfig(network); - return config.rpcUrls; - } - - /** - * Get the primary RPC URL for a specific network - * - * @param network - The network to get the primary RPC URL for - * @returns Primary RPC URL or empty string if none available - */ - getPrimaryRpcUrl(network: Network): string { - const rpcUrls = this.getRpcUrls(network); - return rpcUrls[0] ?? ''; - } -} diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts new file mode 100644 index 00000000..ae21fe69 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -0,0 +1,280 @@ +import { parseCaipAssetType } from '@metamask/utils'; + +import type { TransactionResult } from './types'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import type { Network } from '../../constants'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import type { AccountsService } from '../accounts/AccountsService'; + +export class SendService { + readonly #accountsService: AccountsService; + + readonly #tronWebFactory: TronWebFactory; + + readonly #logger: ILogger; + + constructor({ + accountsService, + tronWebFactory, + logger, + }: { + accountsService: AccountsService; + tronWebFactory: TronWebFactory; + logger: ILogger; + }) { + this.#accountsService = accountsService; + this.#tronWebFactory = tronWebFactory; + this.#logger = createPrefixedLogger(logger, '[💸 SendService]'); + } + + async sendAsset({ + fromAccountId, + toAddress, + amount, + assetId, + }: { + fromAccountId: string; + toAddress: string; + amount: number; + assetId: string; + }): Promise { + const { chainId, assetNamespace, assetReference } = parseCaipAssetType( + assetId as `${string}:${string}/${string}:${string}`, + ); + + try { + switch (assetNamespace) { + case 'slip44': + this.#logger.log('Sending TRX transaction'); + return this.sendTrx({ + scope: chainId as Network, + fromAccountId, + toAddress, + amount, + }); + + case 'trc10': + this.#logger.log(`Sending TRC10 token: ${assetReference}`); + return this.sendTrc10({ + scope: chainId as Network, + fromAccountId, + toAddress, + amount, + tokenId: assetReference, + }); + + case 'trc20': + this.#logger.log(`Sending TRC20 token: ${assetReference}`); + return this.sendTrc20({ + scope: chainId as Network, + fromAccountId, + toAddress, + amount, + contractAddress: assetReference, + }); + + default: + throw new Error(`Unsupported asset namespace: ${assetNamespace}`); + } + } catch (error) { + this.#logger.error({ error }, 'Failed to send asset'); + throw new Error( + `Failed to send asset: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + async sendTrx({ + scope, + fromAccountId, + toAddress, + amount, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + amount: number; + }): Promise { + const account = await this.#accountsService.findById(fromAccountId); + + if (!account) { + throw new Error(`Account with ID ${fromAccountId} not found`); + } + + const keypair = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + // eslint-disable-next-line no-restricted-globals + const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + try { + const amountInSun = amount * 1e6; // Convert TRX to sun + const transaction = await tronWeb.transactionBuilder.sendTrx( + toAddress, + amountInSun, + ); + const signedTx = await tronWeb.trx.sign(transaction); + const result = await tronWeb.trx.sendRawTransaction(signedTx); + + if (!result.result) { + throw new Error( + `Transaction failed: ${result.message || 'Unknown error'}`, + ); + } + + this.#logger.log( + { txId: result.txid }, + 'TRX transaction sent successfully', + ); + + return { + success: true, + txId: result.txid, + transaction: signedTx, + }; + } catch (error) { + this.#logger.error({ error }, 'Failed to send TRX'); + throw new Error( + `Failed to send TRX: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + async sendTrc10({ + scope, + fromAccountId, + toAddress, + amount, + tokenId, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + amount: number; + tokenId: string; + }): Promise { + const account = await this.#accountsService.findById(fromAccountId); + + if (!account) { + throw new Error(`Account with ID ${fromAccountId} not found`); + } + + const keypair = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + // eslint-disable-next-line no-restricted-globals + const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + try { + const transaction = await tronWeb.transactionBuilder.sendToken( + toAddress, + amount, + tokenId, + ); + const signedTx = await tronWeb.trx.sign(transaction); + const result = await tronWeb.trx.sendRawTransaction(signedTx); + + if (!result.result) { + throw new Error( + `Transaction failed: ${result.message || 'Unknown error'}`, + ); + } + + this.#logger.log( + { txId: result.txid }, + 'TRC10 transaction sent successfully', + ); + + return { + success: true, + txId: result.txid, + transaction: signedTx, + }; + } catch (error) { + this.#logger.error({ error }, 'Failed to send TRC10'); + throw new Error( + `Failed to send TRC10: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + async sendTrc20({ + scope, + fromAccountId, + toAddress, + amount, + contractAddress, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + amount: number; + contractAddress: string; + }): Promise { + const account = await this.#accountsService.findById(fromAccountId); + + if (!account) { + throw new Error(`Account with ID ${fromAccountId} not found`); + } + + const keypair = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + // eslint-disable-next-line no-restricted-globals + const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + try { + const functionSelector = 'transfer(address,uint256)'; + const parameter = [ + { type: 'address', value: toAddress }, + { type: 'uint256', value: amount }, + ]; + + const contractResult = + await tronWeb.transactionBuilder.triggerSmartContract( + contractAddress, + functionSelector, + {}, + parameter, + ); + + if (!contractResult.result?.result) { + throw new Error('Failed to create TRC20 transaction'); + } + + const signedTx = await tronWeb.trx.sign(contractResult.transaction); + const result = await tronWeb.trx.sendRawTransaction(signedTx); + + if (!result.result) { + throw new Error( + `Transaction failed: ${result.message || 'Unknown error'}`, + ); + } + + this.#logger.log( + { txId: result.txid }, + 'TRC20 transaction sent successfully', + ); + + return { + success: true, + txId: result.txid, + transaction: signedTx, + }; + } catch (error) { + this.#logger.error({ error }, 'Failed to send TRC20'); + throw new Error( + `Failed to send TRC20: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/send/types.ts b/merged-packages/tron-wallet-snap/src/services/send/types.ts new file mode 100644 index 00000000..7a1a5688 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/types.ts @@ -0,0 +1,5 @@ +export type TransactionResult = { + success: boolean; + txId: string; + transaction: any; +}; diff --git a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts deleted file mode 100644 index 96af09c8..00000000 --- a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ILogger } from '../../utils/logger'; -import type { State, UnencryptedStateValue } from '../state/State'; - -export class WalletService { - readonly #logger: ILogger; - - readonly #loggerPrefix = '[👛 WalletService]'; - - constructor({ - logger, - state: _state, - }: { - logger: ILogger; - state: State; - }) { - this.#logger = logger; - // TODO: Use state when implementing wallet functionality - } - - async signMessage(message: string): Promise { - this.#logger.log(this.#loggerPrefix, 'Signing message...', message); - return '0x1234567890'; // TODO: Implement me - } - - async verifyMessage(message: string, signature: string): Promise { - this.#logger.log( - this.#loggerPrefix, - 'Verifying message...', - message, - signature, - ); - return true; // TODO: Implement me - } -} diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.ts b/merged-packages/tron-wallet-snap/src/validation/structs.ts index e6867ff0..1fa86984 100644 --- a/merged-packages/tron-wallet-snap/src/validation/structs.ts +++ b/merged-packages/tron-wallet-snap/src/validation/structs.ts @@ -13,6 +13,7 @@ import { refine, string, } from '@metamask/superstruct'; +import { TronWeb } from 'tronweb'; import { Network } from '../constants'; @@ -133,8 +134,8 @@ export const UrlStruct = refine(string(), 'safe-url', (value) => { /[?&](?:url|redirect|next|return_to|return_url|goto|destination|continue|redirect_uri)=%(?:[^&]*\/\/|https?:)/iu, // URL-encoded open redirect parameters ]; - for (const dangerousPattern of dangerousPatterns) { - if (dangerousPattern.test(decodedValue)) { + for (const patt of dangerousPatterns) { + if (patt.test(decodedValue)) { return 'URL contains potentially malicious patterns'; } } @@ -145,7 +146,8 @@ export const UrlStruct = refine(string(), 'safe-url', (value) => { } return true; - } catch { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { return 'Invalid URL format'; } }); @@ -293,10 +295,12 @@ export const Base64Struct = pattern( /^(?:[A-Za-z0-9+/]{4})+(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u, ); -const DERIVATION_PATH_REGEX = /^m\/44'\/501'/u; +const DERIVATION_PATH_REGEX = /^m\/44'\/195'/u; + +export const ScopeStringStruct = enums(Object.values(Network)); /** - * Validates a Tron derivation path following the format: m/44'/501'/... + * Validates a Tron derivation path following the format: m/44'/195'/... */ export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); @@ -307,3 +311,21 @@ export const Iso8601Struct = pattern( string(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/u, ); + +export const TronAddressStruct: Struct = define( + 'TronAddress', + (value) => { + if (typeof value !== 'string') { + return `Expected a string, but received: ${typeof value}`; + } + + // Use TronWeb's built-in address validation + const isValidAddress = TronWeb.isAddress(value); + + if (!isValidAddress) { + return 'Invalid Tron address format'; + } + + return true; + }, +); From ec30cfe01e1a7dcd09b598df215296f1b3b003f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:48:09 +0100 Subject: [PATCH 029/238] 1.1.0 (#30) This is the release candidate for version 1.1.0. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 14 +++++++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- .../tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index c927738a..1ee5da9d 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] + +### Added + +- Implement "Unified Non-EVM Send" spec ([#28](https://github.com/MetaMask/snap-tron-wallet/pull/28)) +- Send Staked TRX positions as assets ([#29](https://github.com/MetaMask/snap-tron-wallet/pull/29)) +- Send Energy and Bandwidth as assets ([#27](https://github.com/MetaMask/snap-tron-wallet/pull/27)) +- Implement `discoverAccounts` keyring method ([#26](https://github.com/MetaMask/snap-tron-wallet/pull/26)) +- Support Energy and Bandwidth as transaction history fees ([#25](https://github.com/MetaMask/snap-tron-wallet/pull/25)) +- Implement transaction history ([#19](https://github.com/MetaMask/snap-tron-wallet/pull/19)) + ## [1.0.3] ### Changed @@ -32,7 +43,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.3...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.3...v1.1.0 [1.0.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.0...v1.0.1 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 9a55a097..ab05af80 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.0.3", + "version": "1.1.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index b3e56748..5cbacdbe 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.0.3", + "version": "1.1.0", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "36nyvyI0DNUusuf72wAw0Ot4ITYntf2H0qj1p1Tzc1U=", + "shasum": "Wou05yUsEe89vP8Jzgly8hf4HJgICzmzKC8yU1/q7Ik=", "location": { "npm": { "filePath": "dist/bundle.js", From 180e2396380a91fc8598bed99b66b36e48f37cf7 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 30 Sep 2025 10:37:35 +0100 Subject: [PATCH 030/238] feat: fetch token metadata from Token API instead of Trongrid (#31) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../clients/token-api/TokenApiClient.test.ts | 208 ++++++++++++++++++ .../src/clients/token-api/TokenApiClient.ts | 181 +++++++++++++++ .../src/clients/token-api/structs.ts | 20 ++ .../tron-wallet-snap/src/context.ts | 5 + .../src/services/assets/AssetsService.ts | 96 ++------ .../src/services/assets/types.ts | 20 +- .../transactions/TransactionsMapper.test.ts | 2 +- .../transactions/TransactionsService.test.ts | 4 +- 9 files changed, 442 insertions(+), 96 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts create mode 100644 merged-packages/tron-wallet-snap/src/clients/token-api/structs.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 5cbacdbe..fa623b00 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "Wou05yUsEe89vP8Jzgly8hf4HJgICzmzKC8yU1/q7Ik=", + "shasum": "soF5ChUgf37ZFtneREMGUJ5wBZ/jjz64AHslTkwQu34=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts new file mode 100644 index 00000000..b50d727e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts @@ -0,0 +1,208 @@ +import { TokenApiClient } from './TokenApiClient'; +import { KnownCaip19Id, Network, Networks } from '../../constants'; +import type { TokenCaipAssetType } from '../../services/assets/types'; +import type { ConfigProvider } from '../../services/config'; +import { mockLogger } from '../../utils/mockLogger'; + +const MOCK_METADATA_RESPONSE = [ + { + decimals: 6, + assetId: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + name: 'Tether USD', + symbol: 'USDT', + }, + { + decimals: 18, + assetId: 'tron:728126428/trc20:TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4', + name: 'TrueUSD', + symbol: 'TUSD', + }, +]; + +describe('TokenApiClient', () => { + const mockFetch = jest.fn(); + + let client: TokenApiClient; + let mockConfigProvider: ConfigProvider; + + beforeEach(() => { + jest.clearAllMocks(); + + mockConfigProvider = { + get: jest.fn().mockReturnValue({ + tokenApi: { + baseUrl: 'https://some-mock-url.com', + chunkSize: 50, + }, + staticApi: { + baseUrl: 'https://some-mock-static-url.com', + }, + }), + } as unknown as ConfigProvider; + + client = new TokenApiClient(mockConfigProvider, mockFetch, mockLogger); + }); + + describe('constructor', () => { + it('rejects invalid baseUrl', async () => { + const invalidConfigProvider = { + get: jest.fn().mockReturnValue({ + tokenApi: { + baseUrl: 'invalid-url', + }, + }), + } as unknown as ConfigProvider; + + expect( + () => new TokenApiClient(invalidConfigProvider, mockFetch, mockLogger), + ).toThrow('Invalid URL format'); + }); + }); + + describe('getTokensMetadata', () => { + it('fetches and parses token metadata', async () => { + const tokenAddresses = [ + `${Networks[Network.Mainnet].caip2Id}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType, + `${Networks[Network.Mainnet].caip2Id}/trc20:TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4` as TokenCaipAssetType, + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_METADATA_RESPONSE), + }); + + const metadata = await client.getTokensMetadata(tokenAddresses); + + expect(metadata).toStrictEqual({ + [`tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`]: { + iconUrl: + 'https://some-mock-static-url.com/api/v2/tokenIcons/assets/tron/728126428/trc20/TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t.png', + name: 'Tether USD', + symbol: 'USDT', + fungible: true, + units: [ + { + decimals: 6, + name: 'Tether USD', + symbol: 'USDT', + }, + ], + }, + [`tron:728126428/trc20:TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4`]: { + iconUrl: + 'https://some-mock-static-url.com/api/v2/tokenIcons/assets/tron/728126428/trc20/TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4.png', + name: 'TrueUSD', + symbol: 'TUSD', + fungible: true, + units: [ + { + decimals: 18, + name: 'TrueUSD', + symbol: 'TUSD', + }, + ], + }, + }); + }); + + it('handles addresses in chunks when more than the limit is provided', async () => { + const tokenAddresses = Array.from( + { length: 60 }, + (_, i) => + `${Networks[Network.Nile].caip2Id}/trc20:address${i}` as TokenCaipAssetType, + ); + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_METADATA_RESPONSE), + }); + + await client.getTokensMetadata(tokenAddresses); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('rejects caip19Ids that are invalid', async () => { + await expect( + client.getTokensMetadata(['invalid-caip19-id' as TokenCaipAssetType]), + ).rejects.toThrow( + 'At path: 0 -- Expected a value of type `CaipAssetType`, but received: `"invalid-caip19-id"`', + ); + }); + + it('rejects caip19Ids that include malicious inputs', async () => { + await expect( + client.getTokensMetadata([ + KnownCaip19Id.UsdtMainnet, + 'INVALID' as TokenCaipAssetType, + ]), + ).rejects.toThrow( + 'At path: 1 -- Expected a value of type `CaipAssetType`, but received: `"INVALID"`', + ); + }); + + it('throws an error if fetch fails', async () => { + const tokenAddresses = [ + `${Networks[Network.Nile].caip2Id}/trc20:address0` as TokenCaipAssetType, + `${Networks[Network.Nile].caip2Id}/trc20:address1` as TokenCaipAssetType, + ]; + + const errorMessage = 'Error fetching token metadata'; + mockFetch.mockRejectedValueOnce(new Error(errorMessage)); + + await expect(client.getTokensMetadata(tokenAddresses)).rejects.toThrow( + errorMessage, + ); + expect(mockLogger.error).toHaveBeenCalledWith( + new Error(errorMessage), + errorMessage, + ); + }); + + it('throws an error if the response includes an invalid assetId', async () => { + const tokenAddresses = [ + `${Networks[Network.Nile].caip2Id}/trc20:address0` as TokenCaipAssetType, + `${Networks[Network.Nile].caip2Id}/trc20:address1` as TokenCaipAssetType, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([ + { + decimals: 9, + assetId: 'bad-asset-id', + name: 'Popcat 1', + symbol: 'POPCAT', + }, + ]), + }); + + await expect(client.getTokensMetadata(tokenAddresses)).rejects.toThrow( + 'At path: 0.assetId -- Expected a string matching `/^tron:(728126428|3448148188|2494104990)\\/(trc10|trc20):[a-zA-Z0-9]+$/` but received "bad-asset-id"', + ); + }); + + it('returns default metadata if the asset type is not supported by the Token API', async () => { + const supportedAssetType = + `${Networks[Network.Localnet].caip2Id}/trc10:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr` as TokenCaipAssetType; + const unsupportedAssetType = + `${Networks[Network.Localnet].caip2Id}/trc10:address1` as TokenCaipAssetType; + + const tokenAddresses = [supportedAssetType, unsupportedAssetType]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([MOCK_METADATA_RESPONSE[0]]), + }); + + const metadata = await client.getTokensMetadata(tokenAddresses); + + expect(metadata[supportedAssetType]).toBeDefined(); + expect(metadata[unsupportedAssetType]).toStrictEqual({ + name: 'UNKNOWN', + symbol: 'UNKNOWN', + fungible: true, + iconUrl: '', + units: [{ name: 'UNKNOWN', symbol: 'UNKNOWN', decimals: 9 }], + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts new file mode 100644 index 00000000..4946db84 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts @@ -0,0 +1,181 @@ +import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; +import { array, assert, type Infer } from '@metamask/superstruct'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; + +import { TokenMetadataResponseStruct } from './structs'; +import { Network } from '../../constants'; +import type { TokenCaipAssetType } from '../../services/assets/types'; +import { TokenCaipAssetTypeStruct } from '../../services/assets/types'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import { UrlStruct } from '../../validation/structs'; + +const DEFAULT_DECIMALS = 9; + +const DEFAULT_TOKEN_METADATA: FungibleAssetMetadata = { + name: 'UNKNOWN', + symbol: 'UNKNOWN', + fungible: true, + iconUrl: '', + units: [{ name: 'UNKNOWN', symbol: 'UNKNOWN', decimals: DEFAULT_DECIMALS }], +} as const; + +export class TokenApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + readonly #chunkSize: number; + + readonly #tokenIconBaseUrl: string; + + public static readonly supportedNetworks = [ + Network.Mainnet, + Network.Nile, + Network.Shasta, + ]; + + constructor( + configProvider: ConfigProvider, + _fetch: typeof globalThis.fetch = globalThis.fetch, + _logger: ILogger = logger, + ) { + this.#fetch = _fetch; + this.#logger = _logger; + + const { tokenApi, staticApi } = configProvider.get(); + const { baseUrl, chunkSize } = tokenApi; + + assert(baseUrl, UrlStruct); + + this.#baseUrl = baseUrl; + this.#chunkSize = chunkSize; + this.#tokenIconBaseUrl = staticApi.baseUrl; + } + + async #fetchTokenMetadataBatch( + assetTypes: TokenCaipAssetType[], + ): Promise> { + assert(assetTypes, array(TokenCaipAssetTypeStruct)); + + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/assets', + queryParams: { + assetIds: assetTypes.join(','), + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + assert(data, TokenMetadataResponseStruct); + + return data; + } + + async getTokensMetadata( + assetTypes: TokenCaipAssetType[], + ): Promise> { + try { + assert(assetTypes, array(CaipAssetTypeStruct)); + + // The Token API only supports the networks in TokenApiClient.supportedNetworks + const supportedAssetTypes = assetTypes.filter((assetType) => { + const { chainId } = parseCaipAssetType(assetType); + return TokenApiClient.supportedNetworks.includes(chainId as Network); + }); + + if (supportedAssetTypes.length !== assetTypes.length) { + this.#logger.warn( + `[TokenApiClient] Received some asset types on networks that the Token API doesn't support. They will be ignored. Supported networks: ${TokenApiClient.supportedNetworks.join( + ', ', + )}`, + ); + } + + // Split addresses into chunks + const chunks: TokenCaipAssetType[][] = []; + for ( + let index = 0; + index < supportedAssetTypes.length; + index += this.#chunkSize + ) { + chunks.push(supportedAssetTypes.slice(index, index + this.#chunkSize)); + } + + // Fetch metadata for each chunk + const tokenMetadataResponses = ( + await Promise.all( + chunks.map(async (chunk) => this.#fetchTokenMetadataBatch(chunk)), + ) + ).flat(); + + // Flatten and process all metadata + const tokenMetadataMap = new Map< + TokenCaipAssetType, + FungibleAssetMetadata + >(); + + /** + * Iterate over each asset type, and return a default value when metadata is not found, + * to ensure the returned object has exactly the same keys as the input array. + */ + assetTypes.forEach((assetType) => { + const tokenMetadata = tokenMetadataResponses.find( + (item) => item.assetId === assetType, + ); + + if (!tokenMetadata) { + this.#logger.warn( + `No metadata for ${assetType}. Returning default values.`, + ); + tokenMetadataMap.set(assetType, DEFAULT_TOKEN_METADATA); + return; + } + + const name = tokenMetadata.name ?? DEFAULT_TOKEN_METADATA.name; + const symbol = tokenMetadata.symbol ?? DEFAULT_TOKEN_METADATA.symbol; + const decimals = tokenMetadata.decimals ?? DEFAULT_DECIMALS; + + const metadata: FungibleAssetMetadata = { + name, + symbol, + fungible: true, + iconUrl: + tokenMetadata.iconUrl ?? + buildUrl({ + baseUrl: this.#tokenIconBaseUrl, + path: '/api/v2/tokenIcons/assets/{assetType}.png', + pathParams: { + assetType: assetType.replace(/:/gu, '/'), + }, + }), + units: [ + { + name, + symbol, + decimals, + }, + ], + }; + + tokenMetadataMap.set(assetType, metadata); + }); + + return Object.fromEntries(tokenMetadataMap); + } catch (error) { + this.#logger.error(error, 'Error fetching token metadata'); + throw error; + } + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/structs.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/structs.ts new file mode 100644 index 00000000..442d7fc8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/structs.ts @@ -0,0 +1,20 @@ +import { + array, + integer, + object, + optional, + string, +} from '@metamask/superstruct'; + +import { TokenCaipAssetTypeStruct } from '../../services/assets/types'; +import { UrlStruct } from '../../validation/structs'; + +export const TokenMetadataStruct = object({ + decimals: integer(), + assetId: TokenCaipAssetTypeStruct, + name: optional(string()), + symbol: optional(string()), + iconUrl: optional(UrlStruct), +}); + +export const TokenMetadataResponseStruct = array(TokenMetadataStruct); diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 8cdd08f2..67248362 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -1,6 +1,7 @@ import { InMemoryCache } from './caching/InMemoryCache'; import { PriceApiClient } from './clients/price-api/PriceApiClient'; import { SnapClient } from './clients/snap/SnapClient'; +import { TokenApiClient } from './clients/token-api/TokenApiClient'; import { TronHttpClient } from './clients/tron-http/TronHttpClient'; import { TrongridApiClient } from './clients/trongrid/TrongridApiClient'; import { TronWebFactory } from './clients/tronweb/TronWebFactory'; @@ -66,6 +67,9 @@ const tronWebFactory = new TronWebFactory({ const priceCache = new InMemoryCache(noOpLogger); const priceApiClient = new PriceApiClient(configProvider, priceCache); +// Token API client +const tokenApiClient = new TokenApiClient(configProvider); + // Business Services - depend on Repositories, State and other Services const assetsService = new AssetsService({ logger, @@ -74,6 +78,7 @@ const assetsService = new AssetsService({ trongridApiClient, tronHttpClient, priceApiClient, + tokenApiClient, }); const transactionsService = new TransactionsService({ diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index d6092b5a..1942276b 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -26,6 +26,7 @@ import type { } from './types'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { FiatTicker, SpotPrice } from '../../clients/price-api/types'; +import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { AccountResources } from '../../clients/tron-http'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; @@ -49,6 +50,8 @@ export class AssetsService { readonly #priceApiClient: PriceApiClient; + readonly #tokenApiClient: TokenApiClient; + readonly cacheTtlsMilliseconds: { fiatExchangeRates: number; spotPrices: number; @@ -62,6 +65,7 @@ export class AssetsService { trongridApiClient, tronHttpClient, priceApiClient, + tokenApiClient, }: { logger: ILogger; assetsRepository: AssetsRepository; @@ -69,6 +73,7 @@ export class AssetsService { trongridApiClient: TrongridApiClient; tronHttpClient: TronHttpClient; priceApiClient: PriceApiClient; + tokenApiClient: TokenApiClient; }) { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); this.#assetsRepository = assetsRepository; @@ -76,6 +81,7 @@ export class AssetsService { this.#trongridApiClient = trongridApiClient; this.#tronHttpClient = tronHttpClient; this.#priceApiClient = priceApiClient; + this.#tokenApiClient = tokenApiClient; const { cacheTtlsMilliseconds } = configProvider.get().priceApi; this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; @@ -386,15 +392,16 @@ export class AssetsService { stakedTokensMetadata, energyTokensMetadata, bandwidthTokensMetadata, - trc10TokensMetadata, - trc20TokensMetadata, + tokensMetadata, ] = await Promise.all([ this.#getNativeTokensMetadata(nativeAssetTypes), this.#getStakedTokensMetadata(stakedNativeAssetTypes), this.#getEnergyMetadata(energyAssetTypes), this.#getBandwidthMetadata(bandwidthAssetTypes), - this.#getTRC10TokensMetadata(tokenTrc10AssetTypes), - this.#getTRC20TokensMetadata(tokenTrc20AssetTypes), + this.#getTokensMetadata([ + ...tokenTrc10AssetTypes, + ...tokenTrc20AssetTypes, + ]), ]); const result = { @@ -402,8 +409,7 @@ export class AssetsService { ...stakedTokensMetadata, ...energyTokensMetadata, ...bandwidthTokensMetadata, - ...trc10TokensMetadata, - ...trc20TokensMetadata, + ...tokensMetadata, }; this.#logger.info('Resolved assets metadata', { assetTypes, result }); @@ -591,84 +597,10 @@ export class AssetsService { return bandwidthTokensMetadata; } - async #getTRC10TokensMetadata( - assetTypes: TokenCaipAssetType[], - ): Promise> { - const metadata = await Promise.all( - assetTypes.map(async (assetType) => { - const { chainId, assetReference } = parseCaipAssetType(assetType); - - const tokenMetadata = await this.#tronHttpClient.getTRC10TokenMetadata( - assetReference, - chainId as Network, - ); - - return { - [assetType]: { - name: tokenMetadata.name, - symbol: tokenMetadata.symbol, - fungible: true as const, - iconUrl: `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/assets/${assetReference}/logo.png`, - units: [ - { - name: tokenMetadata.name, - symbol: tokenMetadata.symbol, - decimals: tokenMetadata.decimals, - }, - ], - }, - }; - }), - ); - - return metadata.reduce((acc, curr) => { - return { ...acc, ...curr }; - }, {}); - } - - async #getTRC20TokensMetadata( + async #getTokensMetadata( assetTypes: TokenCaipAssetType[], ): Promise> { - const metadata = await Promise.all( - assetTypes.map(async (assetType) => { - const { assetReference } = parseCaipAssetType(assetType); - const { name, symbol, decimals } = - await this.#getTrc20TokenMetadata(assetType); - - return { - [assetType]: { - name, - symbol, - fungible: true as const, - iconUrl: `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/assets/${assetReference}/logo.png`, - units: [{ name, symbol, decimals }], - }, - }; - }), - ); - - return metadata.reduce((acc, curr) => { - return { ...acc, ...curr }; - }, {}); - } - - async #getTrc20TokenMetadata(assetType: TokenCaipAssetType): Promise<{ - name: string; - symbol: string; - decimals: number; - }> { - const { chainId, assetReference } = parseCaipAssetType(assetType); - - const tokenMetadata = await this.#tronHttpClient.getTRC20TokenMetadata( - assetReference, - chainId as Network, - ); - - return { - name: tokenMetadata.name, - symbol: tokenMetadata.symbol, - decimals: tokenMetadata.decimals, - }; + return this.#tokenApiClient.getTokensMetadata(assetTypes); } /** diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts index afcf0d9f..44e98100 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -10,41 +10,41 @@ export type TokenCaipAssetType = `${TrxScope}/${'trc10' | 'trc20'}:${string}`; export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; /** - * Validates a TRON native CAIP-19 ID (e.g., "tron:mainnet/slip44:195") + * Validates a TRON native CAIP-19 ID (e.g., "tron:728126428/slip44:195") */ export const NativeCaipAssetTypeStruct = pattern( string(), - /^tron:(mainnet|nile|shasta)\/slip44:195$/u, + /^tron:(728126428|3448148188|2494104990)\/slip44:195$/u, ); /** - * Validates a TRON native CAIP-19 ID (e.g., "tron:mainnet/slip44:195") + * Validates a TRON native CAIP-19 ID (e.g., "tron:728126428/slip44:195") */ export const StakedCaipAssetTypeStruct = pattern( string(), - /^tron:(mainnet|nile|shasta)\/slip44:195-staked-for-(energy|bandwidth)$/u, + /^tron:(728126428|3448148188|2494104990)\/slip44:195-staked-for-(energy|bandwidth)$/u, ); /** - * Validates a TRON native CAIP-19 ID for resources (e.g., "tron:mainnet/energy" or "tron:mainnet/bandwidth") + * Validates a TRON native CAIP-19 ID for resources (e.g., "tron:728126428/energy" or "tron:728126428/bandwidth") */ export const ResourceCaipAssetTypeStruct = pattern( string(), - /^tron:(mainnet|nile|shasta)\/slip44:(energy|bandwidth)$/u, + /^tron:(728126428|3448148188|2494104990)\/slip44:(energy|bandwidth)$/u, ); /** - * Validates a TRON token CAIP-19 ID (e.g., "tron:mainnet/trc10:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t") + * Validates a TRON token CAIP-19 ID (e.g., "tron:728126428/trc10:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t") */ export const TokenCaipAssetTypeStruct = pattern( string(), - /^tron:(mainnet|nile|shasta)\/(trc10|trc20):[a-zA-Z0-9]+$/u, + /^tron:(728126428|3448148188|2494104990)\/(trc10|trc20):[a-zA-Z0-9]+$/u, ); /** - * Validates a TRON NFT CAIP-19 ID (e.g., "tron:mainnet/trc721:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + * Validates a TRON NFT CAIP-19 ID (e.g., "tron:728126428/trc721:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") */ export const NftCaipAssetTypeStruct = pattern( string(), - /^tron:(mainnet|nile|shasta)\/trc721:[a-zA-Z0-9]+$/u, + /^tron:(728126428|3448148188|2494104990)\/trc721:[a-zA-Z0-9]+$/u, ); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts index 33932f19..287962bb 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts @@ -18,7 +18,7 @@ describe('TransactionMapper', () => { type: 'eip155:eoa', options: {}, methods: [], - scopes: ['tron:mainnet'], + scopes: ['tron:728126428'], entropySource: 'test-entropy', derivationPath: 'm/0/0', index: 0, diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts index a9f7e6f0..c9669600 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts @@ -29,7 +29,7 @@ describe('TransactionsService', () => { type: 'eip155:eoa', options: {}, methods: [], - scopes: ['tron:mainnet'], + scopes: ['tron:728126428'], entropySource: 'test-entropy', derivationPath: 'm/0/0', index: 0, @@ -41,7 +41,7 @@ describe('TransactionsService', () => { type: 'eip155:eoa', options: {}, methods: [], - scopes: ['tron:mainnet'], + scopes: ['tron:728126428'], entropySource: 'test-entropy', derivationPath: 'm/0/1', index: 1, From 9a18ea322207464f9a5972ce4f58b699d42e476f Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 30 Sep 2025 18:03:22 +0100 Subject: [PATCH 031/238] fix: price and market data request failures (#32) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/price-api/PriceApiClient.ts | 8 ++++++- .../src/clients/token-api/TokenApiClient.ts | 15 ++++++++----- .../tron-wallet-snap/src/constants/index.ts | 7 +++++++ .../tron-wallet-snap/src/handlers/assets.ts | 21 +++---------------- 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index fa623b00..350f32cd 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "soF5ChUgf37ZFtneREMGUJ5wBZ/jjz64AHslTkwQu34=", + "shasum": "YA8Jj4JoNyyzi2pbZsVhl6WsNZp5yBccdRU5xYZs2Xk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts index f4ec5ea2..291e00ca 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts @@ -21,6 +21,7 @@ import { } from './types'; import type { ICache } from '../../caching/ICache'; import { useCache } from '../../caching/useCache'; +import { EXCLUDED_ASSET_SUFFIXES } from '../../constants'; import type { ConfigProvider } from '../../services/config'; import { buildUrl } from '../../utils/buildUrl'; import type { ILogger } from '../../utils/logger'; @@ -254,7 +255,12 @@ export class PriceApiClient { tokenCaip19Types: CaipAssetType[], vsCurrency: VsCurrencyParam | string = 'usd', ): Promise { - return this.#getMultipleSpotPrices_CACHE(tokenCaip19Types, vsCurrency); + const filteredTokens = tokenCaip19Types.filter( + (token) => + !EXCLUDED_ASSET_SUFFIXES.some((suffix) => token.endsWith(suffix)), + ); + + return this.#getMultipleSpotPrices_CACHE(filteredTokens, vsCurrency); } /** diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts index 4946db84..facdc8f7 100644 --- a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts @@ -3,7 +3,7 @@ import { array, assert, type Infer } from '@metamask/superstruct'; import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; import { TokenMetadataResponseStruct } from './structs'; -import { Network } from '../../constants'; +import { EXCLUDED_ASSET_SUFFIXES, Network } from '../../constants'; import type { TokenCaipAssetType } from '../../services/assets/types'; import { TokenCaipAssetTypeStruct } from '../../services/assets/types'; import type { ConfigProvider } from '../../services/config'; @@ -89,17 +89,22 @@ export class TokenApiClient { try { assert(assetTypes, array(CaipAssetTypeStruct)); - // The Token API only supports the networks in TokenApiClient.supportedNetworks + /** + * Exclude TRON resource tokens (energy and bandwidth), staked tokens, and tokens not from supported networks. + */ const supportedAssetTypes = assetTypes.filter((assetType) => { + if ( + EXCLUDED_ASSET_SUFFIXES.some((suffix) => assetType.endsWith(suffix)) + ) { + return false; + } const { chainId } = parseCaipAssetType(assetType); return TokenApiClient.supportedNetworks.includes(chainId as Network); }); if (supportedAssetTypes.length !== assetTypes.length) { this.#logger.warn( - `[TokenApiClient] Received some asset types on networks that the Token API doesn't support. They will be ignored. Supported networks: ${TokenApiClient.supportedNetworks.join( - ', ', - )}`, + `[TokenApiClient] Received some asset types that are either not supported by the Token API or are excluded resource/staked tokens. They will be ignored. Supported networks: ${TokenApiClient.supportedNetworks.join(', ')}`, ); } diff --git a/merged-packages/tron-wallet-snap/src/constants/index.ts b/merged-packages/tron-wallet-snap/src/constants/index.ts index f79513e4..52e7fca1 100644 --- a/merged-packages/tron-wallet-snap/src/constants/index.ts +++ b/merged-packages/tron-wallet-snap/src/constants/index.ts @@ -140,3 +140,10 @@ export const Networks = { bandwidth: TokenMetadata[KnownCaip19Id.BandwidthLocalnet], }, } as const; + +export const EXCLUDED_ASSET_SUFFIXES = [ + '/slip44:energy', + '/slip44:bandwidth', + '/slip44:195-staked-for-energy', + '/slip44:195-staked-for-bandwidth', +]; diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts index eeabcd7b..7baffb9f 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/assets.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -47,25 +47,8 @@ export class AssetsHandler { const { conversions } = params; - const excludedAssetSuffixes = [ - '/slip44:energy', - '/slip44:bandwidth', - '/slip44:195-staked-for-energy', - '/slip44:195-staked-for-bandwidth', - ]; - - const filteredConversions = conversions.filter( - (conversion) => - !excludedAssetSuffixes.some((suffix) => - conversion.from.endsWith(suffix), - ) && - !excludedAssetSuffixes.some((suffix) => conversion.to.endsWith(suffix)), - ); - const conversionRates = - await this.#assetsService.getMultipleTokenConversions( - filteredConversions, - ); + await this.#assetsService.getMultipleTokenConversions(conversions); return { conversionRates, @@ -76,6 +59,7 @@ export class AssetsHandler { params: OnAssetsLookupArguments, ): Promise { const assets = await this.#assetsService.getAssetsMetadata(params.assets); + return { assets }; } @@ -85,6 +69,7 @@ export class AssetsHandler { const marketData = await this.#assetsService.getMultipleTokensMarketData( params.assets, ); + return { marketData }; } } From ca39b9e2a29de85892e4f50fa49865befa1fdd79 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:59:52 +0100 Subject: [PATCH 032/238] 1.1.1 (#33) This is the release candidate for version 1.1.1. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 1ee5da9d..e1ba986e 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.1] + +### Added + +- Fetch token metadata from Token API instead of Trongrid ([#31](https://github.com/MetaMask/snap-tron-wallet/pull/31)) + +### Fixed + +- Price and market data request failures from passing Energy, Bandwidth and other unsupported assets to the API calls directly ([#32](https://github.com/MetaMask/snap-tron-wallet/pull/32)) + ## [1.1.0] ### Added @@ -43,7 +53,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.3...v1.1.0 [1.0.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.1...v1.0.2 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index ab05af80..08e839d2 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.1.0", + "version": "1.1.1", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 350f32cd..f40f0a2e 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", + "version": "1.1.1", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "YA8Jj4JoNyyzi2pbZsVhl6WsNZp5yBccdRU5xYZs2Xk=", + "shasum": "Btozvr+gbeAbhYZanlh/IjElAnZptwI0jTL09DRavdA=", "location": { "npm": { "filePath": "dist/bundle.js", From 8b4ca7d2c31cd5bef405e9a123db171e0750848c Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Wed, 1 Oct 2025 11:14:39 +0200 Subject: [PATCH 033/238] feat: `signAndSendTransaction` client request (#34) --- .../tron-wallet-snap/src/context.ts | 1 + .../handlers/clientRequest/clientRequest.ts | 49 +++++++++++++++++++ .../src/handlers/clientRequest/types.ts | 1 + .../src/handlers/clientRequest/validation.ts | 16 ++++++ 4 files changed, 67 insertions(+) diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 67248362..ffe21c11 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -114,6 +114,7 @@ const clientRequestHandler = new ClientRequestHandler({ accountsService, assetsService, sendService, + tronWebFactory, }); const cronHandler = new CronHandler({ logger, diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 1218b907..8ffc451d 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -12,7 +12,9 @@ import { OnAddressInputRequestStruct, OnAmountInputRequestStruct, OnConfirmSendRequestStruct, + SignAndSendTransactionRequestStruct, } from './validation'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import { Networks } from '../../constants'; import type { AccountsService } from '../../services/accounts/AccountsService'; import type { AssetsService } from '../../services/assets/AssetsService'; @@ -29,21 +31,26 @@ export class ClientRequestHandler { readonly #sendService: SendService; + readonly #tronWebFactory: TronWebFactory; + constructor({ logger, accountsService, assetsService, sendService, + tronWebFactory, }: { logger: ILogger; accountsService: AccountsService; assetsService: AssetsService; sendService: SendService; + tronWebFactory: TronWebFactory; }) { this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); this.#accountsService = accountsService; this.#assetsService = assetsService; this.#sendService = sendService; + this.#tronWebFactory = tronWebFactory; } /** @@ -62,6 +69,8 @@ export class ClientRequestHandler { const { method } = request; switch (method as ClientRequestMethod) { + case ClientRequestMethod.SignAndSendTransaction: + return this.#handleSignAndSendTransaction(request); case ClientRequestMethod.ConfirmSend: return this.#handleConfirmSend(request); case ClientRequestMethod.ComputeFee: @@ -75,6 +84,46 @@ export class ClientRequestHandler { } } + /** + * Handles the signing and sending of a transaction. + * + * @param request - The JSON-RPC request containing transaction details. + * @returns The transaction result with hash and status. + */ + async #handleSignAndSendTransaction(request: JsonRpcRequest): Promise { + try { + assert(request, SignAndSendTransactionRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { transaction, accountId, scope } = request.params; + + const account = await this.#accountsService.findById(accountId); + + if (!account) { + throw new Error(`Account with ID ${accountId} not found`); + } + + const keypair = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + // eslint-disable-next-line no-restricted-globals + const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + const signedTx = await tronWeb.trx.sign(transaction); + const result = await tronWeb.trx.sendHexTransaction(signedTx); + + return { + transactionId: result.txid, + }; + } + /** * Handles the confirmation and sending of a transaction. * diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts index 513d8bd1..3ba2f23b 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts @@ -3,6 +3,7 @@ export enum ClientRequestMethod { ComputeFee = 'computeFee', OnAddressInput = 'onAddressInput', OnAmountInput = 'onAmountInput', + SignAndSendTransaction = 'signAndSendTransaction', } export enum SendErrorCodes { diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index 5d3f0416..ec618991 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -17,6 +17,22 @@ import { UuidStruct, } from '../../validation/structs'; +/** + * signAndSendTransaction request/response validation. + */ +export const SignAndSendTransactionRequestParamsStruct = object({ + transaction: Base64Struct, + accountId: UuidStruct, + scope: ScopeStringStruct, +}); + +export const SignAndSendTransactionRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignAndSendTransaction), + params: SignAndSendTransactionRequestParamsStruct, +}); + /** * onConfirmSend request/response validation. */ From 2af9eb898b3bb0d06230921e8ea2dc02ec61379d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:24:01 +0100 Subject: [PATCH 034/238] 1.2.0 (#36) This is the release candidate for version 1.2.0. --------- Co-authored-by: github-actions Co-authored-by: Alejandro Garcia Anglada --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index e1ba986e..354f239a 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] + +### Added + +- `signAndSendTransaction` client request ([#34](https://github.com/MetaMask/snap-tron-wallet/pull/34)) + ## [1.1.1] ### Added @@ -53,7 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.3...v1.1.0 [1.0.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.2...v1.0.3 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 08e839d2..161be141 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.1.1", + "version": "1.2.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index f40f0a2e..d4335981 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.1.1", + "version": "1.2.0", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "Btozvr+gbeAbhYZanlh/IjElAnZptwI0jTL09DRavdA=", + "shasum": "pyD+UbGJXucD5L3bqgUQ1+6s5vcgCLk8XeSqdddZ50s=", "location": { "npm": { "filePath": "dist/bundle.js", From 50a626ed10fde30733e668d2ee0b72f33af42258 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 9 Oct 2025 15:30:15 +0100 Subject: [PATCH 035/238] feat: implement `computeFee` handler (#40) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/trongrid/TrongridApiClient.ts | 36 +- .../src/clients/trongrid/types.ts | 5 + .../tron-wallet-snap/src/context.ts | 8 + .../handlers/clientRequest/clientRequest.ts | 78 +-- .../src/services/assets/AssetsService.ts | 20 +- .../send/FeeCalculatorService.test.ts | 507 ++++++++++++++++++ .../src/services/send/FeeCalculatorService.ts | 293 ++++++++++ .../src/services/send/types.ts | 12 + .../transactions/TransactionsService.ts | 10 +- 10 files changed, 922 insertions(+), 49 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index d4335981..28989e72 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "pyD+UbGJXucD5L3bqgUQ1+6s5vcgCLk8XeSqdddZ50s=", + "shasum": "DTRyi0dNpZ5n3rNPzYPRWyp648U+ts96DfleJhtFx6E=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts index d16772d5..ece00e9e 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts @@ -1,8 +1,9 @@ import type { + ChainParameter, + ContractTransactionInfo, + TransactionInfo, TronAccount, TrongridApiResponse, - TransactionInfo, - ContractTransactionInfo, } from './types'; import type { Network } from '../../constants'; import type { ConfigProvider } from '../../services/config'; @@ -162,4 +163,35 @@ export class TrongridApiClient { return rawData.data; } + + /** + * Get chain parameters for a specific network. + * + * @see https://api.trongrid.io/wallet/getchainparameters + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @returns Promise - Chain parameters data + */ + async getChainParameters(scope: Network): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const url = `${baseUrl}/wallet/getchainparameters`; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData = await response.json(); + + if (!rawData.chainParameter) { + throw new Error('No chain parameters found'); + } + + return rawData.chainParameter; + } } diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts index 191573b2..15a68667 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts @@ -189,3 +189,8 @@ export type TokenInfo = { decimals: number; name: string; }; + +export type ChainParameter = { + key: string; + value?: number; +}; diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index ffe21c11..03e497b4 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -17,6 +17,7 @@ import { AccountsService } from './services/accounts/AccountsService'; import { AssetsRepository } from './services/assets/AssetsRepository'; import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; +import { FeeCalculatorService } from './services/send/FeeCalculatorService'; import { SendService } from './services/send/SendService'; import type { UnencryptedStateValue } from './services/state/State'; import { State } from './services/state/State'; @@ -96,6 +97,12 @@ const accountsService = new AccountsService({ transactionsService, }); +const feeCalculatorService = new FeeCalculatorService({ + logger, + tronWebFactory, + trongridApiClient, +}); + const sendService = new SendService({ logger, accountsService, @@ -115,6 +122,7 @@ const clientRequestHandler = new ClientRequestHandler({ assetsService, sendService, tronWebFactory, + feeCalculatorService, }); const cronHandler = new CronHandler({ logger, diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 8ffc451d..0d8fe277 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -1,11 +1,10 @@ -import { FeeType, TransactionStatus } from '@metamask/keyring-api'; +import { TransactionStatus } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; import { assert } from '@metamask/superstruct'; import { BigNumber } from 'bignumber.js'; import { ClientRequestMethod, SendErrorCodes } from './types'; -import type { ComputeFeeResponse } from './validation'; import { ComputeFeeRequestStruct, ComputeFeeResponseStruct, @@ -18,9 +17,10 @@ import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import { Networks } from '../../constants'; import type { AccountsService } from '../../services/accounts/AccountsService'; import type { AssetsService } from '../../services/assets/AssetsService'; +import type { FeeCalculatorService } from '../../services/send/FeeCalculatorService'; import type { SendService } from '../../services/send/SendService'; -import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; export class ClientRequestHandler { readonly #logger: ILogger; @@ -33,23 +33,28 @@ export class ClientRequestHandler { readonly #tronWebFactory: TronWebFactory; + readonly #feeCalculatorService: FeeCalculatorService; + constructor({ logger, accountsService, assetsService, sendService, + feeCalculatorService, tronWebFactory, }: { logger: ILogger; accountsService: AccountsService; assetsService: AssetsService; sendService: SendService; + feeCalculatorService: FeeCalculatorService; tronWebFactory: TronWebFactory; }) { this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); this.#accountsService = accountsService; this.#assetsService = assetsService; this.#sendService = sendService; + this.#feeCalculatorService = feeCalculatorService; this.#tronWebFactory = tronWebFactory; } @@ -242,10 +247,11 @@ export class ClientRequestHandler { } /** - * Handles the computation of a fee for a transaction. + * Handles the computation of a fee for a TRON transaction. + * Returns used energy, used bandwidth, and the additional TRX cost breakdown for overages. * * @param request - The JSON-RPC request containing the method and parameters. - * @returns The response to the JSON-RPC request. + * @returns The response to the JSON-RPC request with the detailed fee breakdown. * @throws {InvalidParamsError} If the params are invalid. */ async #handleComputeFee(request: JsonRpcRequest): Promise { @@ -258,35 +264,43 @@ export class ClientRequestHandler { } const { - params: { scope }, + params: { scope, transaction, accountId }, } = request; - // TODO: Implement actual fee computation logic here. - const { baseFee, priorityFee } = { baseFee: '0', priorityFee: '0' }; - - const units = Networks[scope].nativeToken.symbol; - const type = Networks[scope].nativeToken.id; - - const result: ComputeFeeResponse = [ - { - type: FeeType.Base, - asset: { - unit: units, - type, - amount: baseFee, - fungible: true, - }, - }, - { - type: FeeType.Priority, - asset: { - unit: units, - type, - amount: priorityFee, - fungible: true, - }, - }, - ]; + const account = await this.#accountsService.findById(accountId); + + if (!account) { + throw new Error(`Account with ID ${accountId} not found`); + } + + const assets = await this.#assetsService.getAssetsByAccountId(accountId); + + /** + * Get available Energy and Bandwidth from account assets. + */ + const energyAsset = assets.find( + (asset) => asset.assetType === Networks[scope].energy.id, + ); + const bandwidthAsset = assets.find( + (asset) => asset.assetType === Networks[scope].bandwidth.id, + ); + + const availableEnergy = energyAsset + ? BigNumber(energyAsset.rawAmount) + : BigNumber(0); + const availableBandwidth = bandwidthAsset + ? BigNumber(bandwidthAsset.rawAmount) + : BigNumber(0); + + /** + * Calculate complete fee breakdown using the service. + */ + const result = await this.#feeCalculatorService.computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + }); assert(result, ComputeFeeResponseStruct); diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 1942276b..ebcc1593 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -20,9 +20,9 @@ import type { AssetsRepository } from './AssetsRepository'; import type { NativeCaipAssetType, NftCaipAssetType, - TokenCaipAssetType, ResourceCaipAssetType, StakedCaipAssetType, + TokenCaipAssetType, } from './types'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { FiatTicker, SpotPrice } from '../../clients/price-api/types'; @@ -87,6 +87,14 @@ export class AssetsService { this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; } + static isFiat(caipAssetId: CaipAssetType): boolean { + return caipAssetId.includes('swift:0/iso4217:'); + } + + async getAssetsByAccountId(accountId: string): Promise { + return this.#assetsRepository.getByAccountId(accountId); + } + async fetchAssetsAndBalancesForAccount( scope: Network, account: KeyringAccount, @@ -170,16 +178,9 @@ export class AssetsService { return mergedAsset; }); - console.log('[AssetsService]Fetch assets and balances for account'); - console.log(JSON.stringify(assets)); - return assets; } - static isFiat(caipAssetId: CaipAssetType): boolean { - return caipAssetId.includes('swift:0/iso4217:'); - } - #extractNativeAsset({ account, scope, @@ -215,7 +216,6 @@ export class AssetsService { }): AssetEntity[] { const assets: AssetEntity[] = []; - // Calculate staked amounts by type let stakedEnergyAmount = 0; let stakedBandwidthAmount = 0; @@ -230,7 +230,6 @@ export class AssetsService { } }); - // Create staked energy asset if there's any staked energy if (stakedEnergyAmount > 0) { const stakedEnergyAsset: AssetEntity = { assetType: `${scope}/slip44:195-staked-for-energy` as const, @@ -246,7 +245,6 @@ export class AssetsService { assets.push(stakedEnergyAsset); } - // Create staked bandwidth asset if there's any staked bandwidth if (stakedBandwidthAmount > 0) { const stakedBandwidthAsset: AssetEntity = { assetType: diff --git a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts new file mode 100644 index 00000000..8ff88a27 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts @@ -0,0 +1,507 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { FeeType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; + +import { FeeCalculatorService } from './FeeCalculatorService'; +import { Network } from '../../constants'; +import { mockLogger } from '../../utils/mockLogger'; + +const mockTronWebFactory = { + createClient: jest.fn(), +} as any; + +const mockTrongridApiClient = { + getChainParameters: jest.fn(), +} as any; + +const createBase64Transaction = (contractType: string, data: any = {}) => { + const transaction = { + raw_data: { + contract: [ + { + type: contractType, + parameter: { + value: data, + }, + }, + ], + }, + }; + // eslint-disable-next-line no-restricted-globals + return Buffer.from(JSON.stringify(transaction)).toString('base64'); +}; + +describe('FeeCalculatorService', () => { + let feeCalculatorService: FeeCalculatorService; + let mockTronWebClient: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockTronWebClient = { + trx: { + getChainParameters: jest.fn(), + }, + address: { + fromHex: jest.fn((hexAddress) => `base58_${hexAddress}`), + }, + transactionBuilder: { + triggerConstantContract: jest.fn(), + }, + }; + + mockTronWebFactory.createClient.mockReturnValue(mockTronWebClient); + + feeCalculatorService = new FeeCalculatorService({ + logger: mockLogger, + tronWebFactory: mockTronWebFactory, + trongridApiClient: mockTrongridApiClient, + }); + }); + + describe('computeFee', () => { + beforeEach(() => { + // Mock chain parameters response + mockTrongridApiClient.getChainParameters.mockResolvedValue([ + { key: 'getTransactionFee', value: 1000 }, + { key: 'getEnergyFee', value: 100 }, + ]); + }); + + describe('TransferContract scenarios (no energy needed)', () => { + it('has enough bandwidth', async () => { + const transaction = createBase64Transaction('TransferContract'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(1000000); // More than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should only have bandwidth consumption, no TRX cost + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '80000', + fungible: true, + }, + }, + ]); + }); + + it('not enough bandwidth', async () => { + const transaction = createBase64Transaction('TransferContract'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(1000); // Less than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should only have TRX cost for bandwidth overage + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '80.000000', + fungible: true, + }, + }, + ]); + }); + }); + + describe('TriggerSmartContract scenarios (energy needed)', () => { + beforeEach(() => { + // Mock energy calculation for smart contracts + mockTronWebClient.transactionBuilder.triggerConstantContract.mockResolvedValue( + { + energy_used: 50000, + }, + ); + }); + + it('has enough bandwidth + has enough energy', async () => { + const transaction = createBase64Transaction('TriggerSmartContract', { + contract_address: 'a'.repeat(64), + data: '0xa9059cbb000000000000000000000000', + + owner_address: 'b'.repeat(64), + + call_value: 0, + }); + + const availableEnergy = BigNumber(100000); // More than needed (55001) + const availableBandwidth = BigNumber(1000000); // More than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should have both energy and bandwidth consumption, no TRX cost + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '55001', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '311000', + fungible: true, + }, + }, + ]); + }); + + it('has enough bandwidth + not enough energy', async () => { + const transaction = createBase64Transaction('TriggerSmartContract', { + contract_address: 'a'.repeat(64), + data: '0xa9059cbb000000000000000000000000', + + owner_address: 'b'.repeat(64), + + call_value: 0, + }); + + const availableEnergy = BigNumber(30000); // Less than needed (55001) + const availableBandwidth = BigNumber(1000000); // More than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should have bandwidth consumption and TRX cost for energy overage + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '30000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '311000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '2.500100', + fungible: true, + }, + }, + ]); + }); + + it('not enough bandwidth + has enough energy', async () => { + const largeTransaction = createBase64Transaction( + 'TriggerSmartContract', + { + contract_address: 'a'.repeat(64), + data: 'b'.repeat(2000), // Large data to increase bandwidth + owner_address: 'c'.repeat(64), + call_value: 0, + }, + ); + + const availableEnergy = BigNumber(100000); // More than needed (55001) + const availableBandwidth = BigNumber(1000); // Less than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: largeTransaction, + availableEnergy, + availableBandwidth, + }); + + // Should have energy consumption and TRX cost for bandwidth overage + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '55001', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '2277.000000', + fungible: true, + }, + }, + ]); + }); + + it('not enough bandwidth + not enough energy', async () => { + const largeTransaction = createBase64Transaction( + 'TriggerSmartContract', + { + contract_address: 'a'.repeat(64), + data: 'b'.repeat(2000), // Large data to increase bandwidth + owner_address: 'c'.repeat(64), + call_value: 0, + }, + ); + + const availableEnergy = BigNumber(30000); // Less than needed (55001) + const availableBandwidth = BigNumber(1000); // Less than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: largeTransaction, + availableEnergy, + availableBandwidth, + }); + + // Should have energy consumption and TRX cost for both energy and bandwidth overages + // Note: bandwidth consumption is 0 when there's not enough bandwidth, so it gets filtered out + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '30000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '2279.500100', + fungible: true, + }, + }, + ]); + }); + }); + + describe('Edge cases and error handling', () => { + it('should filter out zero amount fees', async () => { + const transaction = createBase64Transaction('TransferContract'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(0); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should only have TRX cost, no zero amount fees + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '80.000000', + fungible: true, + }, + }, + ]); + }); + + it('should handle different networks correctly', async () => { + const transaction = createBase64Transaction('TransferContract'); + const availableEnergy = BigNumber(100000); + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Shasta, + transaction, + availableEnergy, + availableBandwidth, + }); + + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:2494104990/slip44:bandwidth', + amount: '80000', + fungible: true, + }, + }, + ]); + }); + + it('should use fallback values when chain parameters are missing', async () => { + mockTrongridApiClient.getChainParameters.mockResolvedValue([]); + + const transaction = createBase64Transaction('TriggerSmartContract', { + contract_address: 'a'.repeat(64), + data: '0xa9059cbb000000000000000000000000', + owner_address: 'b'.repeat(64), + call_value: 0, + }); + + mockTronWebClient.transactionBuilder.triggerConstantContract.mockResolvedValue( + { + energy_used: 150000, + }, + ); + + const availableEnergy = BigNumber(100000); + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should use fallback values: energyFee=100, bandwidthFee=1000 + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '100000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '311000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '6.500000', + fungible: true, + }, + }, + ]); + }); + + it('should handle very large transactions', async () => { + const veryLargeTransaction = createBase64Transaction( + 'TriggerSmartContract', + { + contract_address: 'a'.repeat(64), + data: 'b'.repeat(10000), // Very large data + + owner_address: 'c'.repeat(64), + + call_value: 0, + }, + ); + + mockTronWebClient.transactionBuilder.triggerConstantContract.mockResolvedValue( + { + energy_used: 50000, + }, + ); + + const availableEnergy = BigNumber(100000); + const availableBandwidth = BigNumber(1000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: veryLargeTransaction, + availableEnergy, + availableBandwidth, + }); + + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '55001', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '10277.000000', + fungible: true, + }, + }, + ]); + }); + + it('should handle exact resource matches', async () => { + const transaction = createBase64Transaction('TransferContract'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(80000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '80000', + fungible: true, + }, + }, + ]); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts new file mode 100644 index 00000000..bf1853f8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts @@ -0,0 +1,293 @@ +import { FeeType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; +import type { Contract } from 'tronweb'; + +import type { ComputeFeeResult } from './types'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import type { Network } from '../../constants'; +import { Networks } from '../../constants'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; + +export class FeeCalculatorService { + readonly #logger: ILogger; + + readonly #tronWebFactory: TronWebFactory; + + readonly #trongridApiClient: TrongridApiClient; + + constructor({ + logger, + tronWebFactory, + trongridApiClient, + }: { + logger: ILogger; + tronWebFactory: TronWebFactory; + trongridApiClient: TrongridApiClient; + }) { + this.#logger = createPrefixedLogger(logger, '[💸 FeeCalculatorService]'); + this.#tronWebFactory = tronWebFactory; + this.#trongridApiClient = trongridApiClient; + } + + /** + * Calculate the energy requirements for a TRON transaction + * + * @param scope - The network scope for the transaction + * @param transaction - The base64 encoded transaction + * @returns Promise - The calculated energy consumption + */ + async #calculateEnergy( + scope: Network, + transaction: string, + ): Promise { + try { + // Decode the transaction from base64 + let txObj: any = null; + + try { + // eslint-disable-next-line no-restricted-globals + txObj = JSON.parse(Buffer.from(transaction, 'base64').toString()); + } catch (parseError) { + this.#logger.warn({ error: parseError }, 'Failed to parse transaction'); + throw new Error('Invalid transaction format'); + } + + if (!txObj?.raw_data?.contract?.[0]) { + throw new Error('Invalid transaction structure'); + } + + const contract = txObj.raw_data.contract[0]; + const contractType = contract.type; + + this.#logger.log(`Calculating energy for contract type: ${contractType}`); + + switch (contractType) { + case 'TransferContract': + // Native TRX transfers don't consume energy + return BigNumber(0); + + case 'TransferAssetContract': + // TRC10 token transfers don't consume energy + return BigNumber(0); + + case 'TriggerSmartContract': { + // For smart contracts, try to estimate energy using triggerconstantcontract + return BigNumber( + await this.#estimateSmartContractEnergy(scope, contract), + ); + } + + default: + this.#logger.warn( + `Unknown contract type: ${contractType}, using conservative estimate`, + ); + return BigNumber(100000); // Conservative estimate + } + } catch (error) { + this.#logger.error({ error }, 'Failed to calculate energy usage'); + throw error; + } + } + + /** + * The bandwidth needed for a transaction is the size of the transaction in bytes + * multiplied by the price per byte, which is currently 1000 SUN per byte. + * + * @param transaction - The base64 encoded transaction + * @returns number - The calculated bandwidth in SUN + */ + #calculateBandwidth(transaction: string): BigNumber { + // eslint-disable-next-line no-restricted-globals + const decodedBytes = Buffer.from(transaction, 'base64'); + const byteSize = decodedBytes.length; + return BigNumber(byteSize * 1000); + } + + /** + * Estimate energy consumption for smart contract execution + * Uses the triggerconstantcontract API to simulate execution + * + * @param scope - The network scope for the contract + * @param contract - The contract object from the transaction + * @returns Promise - The estimated energy consumption + */ + async #estimateSmartContractEnergy( + scope: Network, + contract: Contract, + ): Promise { + try { + this.#logger.log( + 'Estimating smart contract energy', + JSON.stringify(contract), + ); + + const tronWeb = this.#tronWebFactory.createClient(scope); + + const contractAddress = contract.parameter.value.contract_address; + const functionSelector = contract.parameter.value.data; + const ownerAddress = contract.parameter.value.owner_address; + const callValue = contract.parameter.value.call_value ?? 0; + + // Convert addresses from hex to base58 if needed + const contractAddressBase58 = tronWeb.address.fromHex( + `41${contractAddress}`, + ); + const ownerAddressBase58 = tronWeb.address.fromHex(`41${ownerAddress}`); + + this.#logger.log( + { + contractAddress: contractAddressBase58, + ownerAddress: ownerAddressBase58, + functionSelector, + callValue, + }, + 'Estimating smart contract energy', + ); + + // Call triggerconstantcontract to simulate execution + const result = await tronWeb.transactionBuilder.triggerConstantContract( + contractAddressBase58, + functionSelector, + {}, + [], + ownerAddressBase58, + ); + + if (result?.energy_used) { + const energyUsed = result.energy_used; + this.#logger.log({ energyUsed }, 'Energy estimation from network'); + + // Add a 10% buffer for safety (execution might vary slightly) + return Math.ceil(energyUsed * 1.1); + } + + // If no energy_used in result, use typical TRC20 transfer energy + this.#logger.warn( + 'No energy_used in simulation result, using typical TRC20 value', + ); + return 65000; // Typical for TRC20 transfer + } catch (error) { + this.#logger.error( + { error }, + 'Failed to estimate smart contract energy, using fallback', + ); + + // Fallback to conservative estimate + // TRC20 transfers typically use ~31,000-65,000 energy + // Complex contracts can use much more + return 100000; // Conservative estimate for unknown contracts + } + } + + /** + * Calculate complete fee breakdown for a TRON transaction + * Handles both free resource consumption and TRX costs for overages + * + * @param params - The parameters for the fee calculation + * @param params.scope - The network scope for the transaction + * @param params.transaction - The base64 encoded transaction + * @param params.availableEnergy - Available energy from account + * @param params.availableBandwidth - Available bandwidth from account + * @returns Promise - Complete fee breakdown + */ + async computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + }: { + scope: Network; + transaction: string; + availableEnergy: BigNumber; + availableBandwidth: BigNumber; + }): Promise { + this.#logger.log( + 'Calculating fee for transaction ', + JSON.stringify(transaction), + ); + + const bandwidthNeeded = this.#calculateBandwidth(transaction); + const energyNeeded = await this.#calculateEnergy(scope, transaction); + + // Calculate consumption and overages: + // - Bandwidth: If we don't have enough, we pay for ALL of it in TRX (no partial consumption) + // - Energy: We consume what we have available, and pay TRX only for the overage + const hasEnoughBandwidth = + availableBandwidth.isGreaterThanOrEqualTo(bandwidthNeeded); + const bandwidthConsumed = hasEnoughBandwidth + ? bandwidthNeeded + : BigNumber(0); + const bandwidthToPayInTRX = hasEnoughBandwidth + ? BigNumber(0) + : bandwidthNeeded; + + const energyConsumed = BigNumber.min(energyNeeded, availableEnergy); + const energyToPayInTRX = BigNumber.max( + energyNeeded.minus(availableEnergy), + 0, + ); + + const result: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: Networks[scope].energy.symbol, + type: Networks[scope].energy.id, + amount: energyConsumed.toString(), + fungible: true as const, + }, + }, + { + type: FeeType.Base, + asset: { + unit: Networks[scope].bandwidth.symbol, + type: Networks[scope].bandwidth.id, + amount: bandwidthConsumed.toString(), + fungible: true as const, + }, + }, + ]; + + // If we need to pay TRX for bandwidth or energy overages + if ( + bandwidthToPayInTRX.isGreaterThan(0) || + energyToPayInTRX.isGreaterThan(0) + ) { + const chainParameters = + await this.#trongridApiClient.getChainParameters(scope); + + const bandwidthCost = + chainParameters.find((param) => param.key === 'getTransactionFee') + ?.value ?? 1000; // Fallback to 1000 SUN per bandwidth + const energyCost = + chainParameters.find((param) => param.key === 'getEnergyFee')?.value ?? + 100; // Fallback to 100 SUN per energy + + // Calculate TRX cost for bandwidth and energy that needs to be paid + const bandwidthCostTRX = bandwidthToPayInTRX + .multipliedBy(bandwidthCost) + .div(1_000_000); // Convert SUN to TRX + const energyCostTRX = energyToPayInTRX + .multipliedBy(energyCost) + .div(1_000_000); // Convert SUN to TRX + const totalCostTRX = bandwidthCostTRX.plus(energyCostTRX); + + result.push({ + type: FeeType.Base, + asset: { + unit: Networks[scope].nativeToken.symbol, + type: Networks[scope].nativeToken.id, + amount: totalCostTRX.toFixed(6), + fungible: true as const, + }, + }); + } + + // Filter out any fees with zero amounts + return result.filter( + (fee) => fee.asset.fungible && fee.asset.amount !== '0', + ); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/send/types.ts b/merged-packages/tron-wallet-snap/src/services/send/types.ts index 7a1a5688..b2d8d5ad 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/types.ts @@ -1,5 +1,17 @@ +import type { FeeType } from '@metamask/keyring-api'; + export type TransactionResult = { success: boolean; txId: string; transaction: any; }; + +export type ComputeFeeResult = { + type: FeeType; + asset: { + unit: string; + type: string; + amount: string; + fungible: true; + }; +}[]; diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts index 26a1615c..2ba67a41 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts @@ -96,8 +96,12 @@ export class TransactionsService { const transactionsByAccountId = groupBy(transactions, 'account'); - await emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { - transactions: transactionsByAccountId, - }); + await emitSnapKeyringEvent( + snap as any, + KeyringEvent.AccountTransactionsUpdated, + { + transactions: transactionsByAccountId, + }, + ); } } From 0ca445dd6207fe1d3880f00d2623010414127b23 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 9 Oct 2025 16:45:15 +0100 Subject: [PATCH 036/238] chore: add new required fields to KeyringAccount objects (#41) --- merged-packages/tron-wallet-snap/package.json | 2 +- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/services/accounts/AccountsService.ts | 16 ++++++++++------ .../services/transactions/TransactionsService.ts | 10 +++------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 161be141..8ba1796a 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "patch:@metamask/keyring-api@npm%3A18.0.0#~/.yarn/patches/@metamask-keyring-api-npm-18.0.0-1987f8da30.patch", + "@metamask/keyring-api": "^21.1.0", "@metamask/keyring-snap-sdk": "^4.0.0", "@metamask/snaps-cli": "^8.1.0", "@metamask/snaps-jest": "^9.2.0", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 28989e72..7f1b7632 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "DTRyi0dNpZ5n3rNPzYPRWyp648U+ts96DfleJhtFx6E=", + "shasum": "+qcPpY/fpSa/cDgxrnOJt3P2fxut1WCwmjSIR55ugpk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index b68e3429..e0a0d651 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -2,10 +2,12 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { assert, integer, pattern, string } from '@metamask/superstruct'; -import { hexToBytes } from '@metamask/utils'; import type { Json } from '@metamask/utils'; +import { hexToBytes } from '@metamask/utils'; import { TronWeb } from 'tronweb'; +import type { AccountsRepository } from './AccountsRepository'; +import type { CreateAccountOptions } from './types'; import type { SnapClient } from '../../clients/snap/SnapClient'; import { asStrictKeyringAccount, @@ -15,8 +17,6 @@ import { getLowestUnusedIndex } from '../../utils/getLowestUnusedIndex'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; import type { AssetsService } from '../assets/AssetsService'; import type { ConfigProvider } from '../config'; -import type { AccountsRepository } from './AccountsRepository'; -import type { CreateAccountOptions } from './types'; import type { TransactionsService } from '../transactions/TransactionsService'; /** @@ -160,9 +160,13 @@ export class AccountsService { address, scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], options: { - entropySource, - derivationPath, - index: accountIndex, + entropy: { + type: 'mnemonic', + id: entropySource, + derivationPath, + groupIndex: accountIndex, + }, + exportable: false, }, methods: ['signMessageV2', 'verifyMessageV2'], }; diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts index 2ba67a41..26a1615c 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts @@ -96,12 +96,8 @@ export class TransactionsService { const transactionsByAccountId = groupBy(transactions, 'account'); - await emitSnapKeyringEvent( - snap as any, - KeyringEvent.AccountTransactionsUpdated, - { - transactions: transactionsByAccountId, - }, - ); + await emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { + transactions: transactionsByAccountId, + }); } } From c42d52067cc1711a81c7e61256ed483743bc8802 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 10 Oct 2025 10:06:43 +0100 Subject: [PATCH 037/238] feat: implement account synchronization when transactions happen (#38) --- .../tron-wallet-snap/snap.manifest.json | 3 +- .../tron-wallet-snap/src/context.ts | 2 + .../handlers/clientRequest/clientRequest.ts | 45 ++++++++++--- .../tron-wallet-snap/src/handlers/cronjob.ts | 63 ++++++++++++------ .../src/handlers/lifecycle.ts | 15 +---- .../src/services/send/SendService.ts | 64 +++++++++++++++++++ 6 files changed, 149 insertions(+), 43 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 7f1b7632..7bc649b3 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "+qcPpY/fpSa/cDgxrnOJt3P2fxut1WCwmjSIR55ugpk=", + "shasum": "o4qZgOlGwPQS+Oxy9KRan3hLaxGLtVwv5mhnl93s+JM=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -44,6 +44,7 @@ "curve": "secp256k1" } ], + "endowment:lifecycle-hooks": {}, "endowment:network-access": {}, "snap_manageAccounts": {}, "snap_manageState": {}, diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 03e497b4..d59b1634 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -107,6 +107,7 @@ const sendService = new SendService({ logger, accountsService, tronWebFactory, + snapClient, }); /** @@ -123,6 +124,7 @@ const clientRequestHandler = new ClientRequestHandler({ sendService, tronWebFactory, feeCalculatorService, + snapClient, }); const cronHandler = new CronHandler({ logger, diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 0d8fe277..2a4c0af3 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -4,6 +4,16 @@ import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; import { assert } from '@metamask/superstruct'; import { BigNumber } from 'bignumber.js'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import { Networks } from '../../constants'; +import type { AccountsService } from '../../services/accounts/AccountsService'; +import type { AssetsService } from '../../services/assets/AssetsService'; +import type { FeeCalculatorService } from '../../services/send/FeeCalculatorService'; +import type { SendService } from '../../services/send/SendService'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import { BackgroundEventMethod } from '../cronjob'; import { ClientRequestMethod, SendErrorCodes } from './types'; import { ComputeFeeRequestStruct, @@ -13,14 +23,6 @@ import { OnConfirmSendRequestStruct, SignAndSendTransactionRequestStruct, } from './validation'; -import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; -import { Networks } from '../../constants'; -import type { AccountsService } from '../../services/accounts/AccountsService'; -import type { AssetsService } from '../../services/assets/AssetsService'; -import type { FeeCalculatorService } from '../../services/send/FeeCalculatorService'; -import type { SendService } from '../../services/send/SendService'; -import type { ILogger } from '../../utils/logger'; -import { createPrefixedLogger } from '../../utils/logger'; export class ClientRequestHandler { readonly #logger: ILogger; @@ -35,6 +37,8 @@ export class ClientRequestHandler { readonly #feeCalculatorService: FeeCalculatorService; + readonly #snapClient: SnapClient; + constructor({ logger, accountsService, @@ -42,6 +46,7 @@ export class ClientRequestHandler { sendService, feeCalculatorService, tronWebFactory, + snapClient, }: { logger: ILogger; accountsService: AccountsService; @@ -49,6 +54,7 @@ export class ClientRequestHandler { sendService: SendService; feeCalculatorService: FeeCalculatorService; tronWebFactory: TronWebFactory; + snapClient: SnapClient; }) { this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); this.#accountsService = accountsService; @@ -56,6 +62,7 @@ export class ClientRequestHandler { this.#sendService = sendService; this.#feeCalculatorService = feeCalculatorService; this.#tronWebFactory = tronWebFactory; + this.#snapClient = snapClient; } /** @@ -124,6 +131,15 @@ export class ClientRequestHandler { const signedTx = await tronWeb.trx.sign(transaction); const result = await tronWeb.trx.sendHexTransaction(signedTx); + /** + * Sync account after a transaction + */ + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId }, + duration: 'PT5S', + }); + return { transactionId: result.txid, }; @@ -153,6 +169,15 @@ export class ClientRequestHandler { assetId, }); + /** + * Sync account after a transaction + */ + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: fromAccountId }, + duration: 'PT5S', + }); + return { transactionId: transaction.txId, status: TransactionStatus.Submitted, @@ -175,7 +200,9 @@ export class ClientRequestHandler { throw errorToThrow; } - // If we reach this point, the address is valid (validated by TronAddressStruct) + /** + * If we reach this point, the address is valid (validated by TronAddressStruct) + */ return { valid: true, errors: [], diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts index 41ca14b6..2e4bc6a1 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts @@ -5,12 +5,10 @@ import type { AccountsService } from '../services/accounts/AccountsService'; import type { ILogger } from '../utils/logger'; import { createPrefixedLogger } from '../utils/logger'; -export enum CronMethod { - SynchronizeAccounts = 'scheduleRefreshAccounts', -} - export enum BackgroundEventMethod { - SyncAccountTransactions = 'onSyncAccountTransactions', + ContinuouslySynchronizeAccounts = 'onContinuouslySynchronizeAccounts', + SynchronizeAccounts = 'onSynchronizeAccounts', + SynchronizeAccount = 'onSynchronizeAccount', } export class CronHandler { @@ -42,14 +40,15 @@ export class CronHandler { return; } - switch (method as CronMethod | BackgroundEventMethod) { - case CronMethod.SynchronizeAccounts: + switch (method as BackgroundEventMethod) { + case BackgroundEventMethod.ContinuouslySynchronizeAccounts: + await this.continuouslySynchronizeAccounts(); + break; + case BackgroundEventMethod.SynchronizeAccounts: await this.synchronizeAccounts(); break; - case BackgroundEventMethod.SyncAccountTransactions: - await this.synchronizeAccountTransactions( - (params as { accountId: string })?.accountId, - ); + case BackgroundEventMethod.SynchronizeAccount: + await this.synchronizeAccount(params as { accountId: string }); break; default: throw new Error(`Unknown cronjob method: ${method}`); @@ -57,11 +56,11 @@ export class CronHandler { } /** - * Synchronizes all accounts (assets and transactions). - * This can be called by cron jobs to keep data fresh. + * A background job that continuosly synchronizes all accounts. + * It schedules itself while the extension is active to make sure the data is fresh. */ - async synchronizeAccounts(): Promise { - this.#logger.info('Synchronizing accounts...'); + async continuouslySynchronizeAccounts(): Promise { + this.#logger.info('[Tick] Continuously synchronizing accounts...'); const { active } = await this.#snapClient.getClientStatus(); @@ -69,17 +68,39 @@ export class CronHandler { return; } - const accounts = await this.#accountsService.getAll(); - await this.#accountsService.synchronize(accounts); + await this.synchronizeAccounts(); await this.#snapClient.scheduleBackgroundEvent({ - method: CronMethod.SynchronizeAccounts, + method: BackgroundEventMethod.ContinuouslySynchronizeAccounts, duration: '30s', }); } - async synchronizeAccountTransactions(accountId: string): Promise { - this.#logger.info(`Synchronizing transactions for account ${accountId}...`); + async synchronizeAccounts(): Promise { + this.#logger.info('Synchronizing all accounts...'); + + const { active } = await this.#snapClient.getClientStatus(); + + if (!active) { + return; + } + + const accounts = await this.#accountsService.getAll(); + await this.#accountsService.synchronize(accounts); + } + + async synchronizeAccount({ + accountId, + }: { + accountId: string; + }): Promise { + this.#logger.info(`Synchronizing account ${accountId}...`); + + const { active } = await this.#snapClient.getClientStatus(); + + if (!active) { + return; + } const account = await this.#accountsService.findById(accountId); @@ -87,6 +108,6 @@ export class CronHandler { return; } - await this.#accountsService.synchronizeTransactions([account]); + await this.#accountsService.synchronize([account]); } } diff --git a/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts b/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts index d7b5b27b..f2f34666 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts @@ -1,27 +1,21 @@ -import { CronMethod } from './cronjob'; +import { BackgroundEventMethod } from './cronjob'; import type { SnapClient } from '../clients/snap/SnapClient'; -import type { AccountsService } from '../services/accounts/AccountsService'; import type { ILogger } from '../utils/logger'; import { createPrefixedLogger } from '../utils/logger'; export class LifecycleHandler { readonly #logger: ILogger; - readonly #accountsService: AccountsService; - readonly #snapClient: SnapClient; constructor({ logger, - accountsService, snapClient, }: { logger: ILogger; - accountsService: AccountsService; snapClient: SnapClient; }) { this.#logger = createPrefixedLogger(logger, '[👵 LifecycleHandler]'); - this.#accountsService = accountsService; this.#snapClient = snapClient; } @@ -31,12 +25,9 @@ export class LifecycleHandler { async onActive(): Promise { this.#logger.log('[🔋 onActive]'); - const accounts = await this.#accountsService.getAll(); - await this.#accountsService.synchronize(accounts); - await this.#snapClient.scheduleBackgroundEvent({ - method: CronMethod.SynchronizeAccounts, - duration: '10s', + method: BackgroundEventMethod.ContinuouslySynchronizeAccounts, + duration: '1s', }); } } diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts index ae21fe69..50f158f7 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -1,8 +1,10 @@ import { parseCaipAssetType } from '@metamask/utils'; import type { TransactionResult } from './types'; +import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import type { Network } from '../../constants'; +import { BackgroundEventMethod } from '../../handlers/cronjob'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; import type { AccountsService } from '../accounts/AccountsService'; @@ -13,18 +15,23 @@ export class SendService { readonly #logger: ILogger; + readonly #snapClient: SnapClient; + constructor({ accountsService, tronWebFactory, logger, + snapClient, }: { accountsService: AccountsService; tronWebFactory: TronWebFactory; logger: ILogger; + snapClient: SnapClient; }) { this.#accountsService = accountsService; this.#tronWebFactory = tronWebFactory; this.#logger = createPrefixedLogger(logger, '[💸 SendService]'); + this.#snapClient = snapClient; } async sendAsset({ @@ -130,6 +137,25 @@ export class SendService { 'TRX transaction sent successfully', ); + /** + * Check if the transaction's destination address is also managed by the snap. + * If so, sync the account. Otherwise, only sync the source account. + */ + const accountIdsToSync = [fromAccountId]; + + const destinationAccount = + await this.#accountsService.findByAddress(toAddress); + + if (destinationAccount) { + accountIdsToSync.push(destinationAccount.id); + } + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccounts, + params: { accountIds: accountIdsToSync }, + duration: 'PT5S', // Wait 5 seconds before syncing to allow transaction to be processed + }); + return { success: true, txId: result.txid, @@ -191,6 +217,25 @@ export class SendService { 'TRC10 transaction sent successfully', ); + /** + * Check if the transaction's destination address is also managed by the snap. + * If so, sync the account. Otherwise, only sync the source account. + */ + const accountIdsToSync = [fromAccountId]; + + const destinationAccount = + await this.#accountsService.findByAddress(toAddress); + + if (destinationAccount) { + accountIdsToSync.push(destinationAccount.id); + } + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccounts, + params: { accountIds: accountIdsToSync }, + duration: 'PT5S', // Wait 5 seconds before syncing to allow transaction to be processed + }); + return { success: true, txId: result.txid, @@ -265,6 +310,25 @@ export class SendService { 'TRC20 transaction sent successfully', ); + /** + * Check if the transaction's destination address is also managed by the snap. + * If so, sync the account. Otherwise, only sync the source account. + */ + const accountIdsToSync = [fromAccountId]; + + const destinationAccount = + await this.#accountsService.findByAddress(toAddress); + + if (destinationAccount) { + accountIdsToSync.push(destinationAccount.id); + } + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccounts, + params: { accountIds: accountIdsToSync }, + duration: 'PT5S', // Wait 5 seconds before syncing to allow transaction to be processed + }); + return { success: true, txId: result.txid, From d0139c58f421e72979dc9b77071f86fdbbe75988 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 10 Oct 2025 11:48:59 +0100 Subject: [PATCH 038/238] fix: missing sync transactions background event (#44) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../tron-wallet-snap/src/context.ts | 1 - .../tron-wallet-snap/src/handlers/cronjob.ts | 35 +++++++++++-------- .../tron-wallet-snap/src/handlers/keyring.ts | 2 +- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 7bc649b3..3780464d 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "o4qZgOlGwPQS+Oxy9KRan3hLaxGLtVwv5mhnl93s+JM=", + "shasum": "3kPqlK0NwV+/dJHpzd6PqVIW4LMdIeB/B1UOYF/POzY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index d59b1634..2299d986 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -133,7 +133,6 @@ const cronHandler = new CronHandler({ }); const lifecycleHandler = new LifecycleHandler({ logger, - accountsService, snapClient, }); const keyringHandler = new KeyringHandler({ diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts index 2e4bc6a1..d829aacb 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts @@ -9,6 +9,7 @@ export enum BackgroundEventMethod { ContinuouslySynchronizeAccounts = 'onContinuouslySynchronizeAccounts', SynchronizeAccounts = 'onSynchronizeAccounts', SynchronizeAccount = 'onSynchronizeAccount', + SynchronizeAccountTransactions = 'onSynchronizeAccountTransactions', } export class CronHandler { @@ -50,6 +51,11 @@ export class CronHandler { case BackgroundEventMethod.SynchronizeAccount: await this.synchronizeAccount(params as { accountId: string }); break; + case BackgroundEventMethod.SynchronizeAccountTransactions: + await this.synchronizeAccountTransactions( + params as { accountId: string }, + ); + break; default: throw new Error(`Unknown cronjob method: ${method}`); } @@ -62,12 +68,6 @@ export class CronHandler { async continuouslySynchronizeAccounts(): Promise { this.#logger.info('[Tick] Continuously synchronizing accounts...'); - const { active } = await this.#snapClient.getClientStatus(); - - if (!active) { - return; - } - await this.synchronizeAccounts(); await this.#snapClient.scheduleBackgroundEvent({ @@ -79,13 +79,8 @@ export class CronHandler { async synchronizeAccounts(): Promise { this.#logger.info('Synchronizing all accounts...'); - const { active } = await this.#snapClient.getClientStatus(); - - if (!active) { - return; - } - const accounts = await this.#accountsService.getAll(); + await this.#accountsService.synchronize(accounts); } @@ -96,18 +91,28 @@ export class CronHandler { }): Promise { this.#logger.info(`Synchronizing account ${accountId}...`); - const { active } = await this.#snapClient.getClientStatus(); + const account = await this.#accountsService.findById(accountId); - if (!active) { + if (!account) { return; } + await this.#accountsService.synchronize([account]); + } + + async synchronizeAccountTransactions({ + accountId, + }: { + accountId: string; + }): Promise { + this.#logger.info(`Synchronizing account transactions ${accountId}...`); + const account = await this.#accountsService.findById(accountId); if (!account) { return; } - await this.#accountsService.synchronize([account]); + await this.#accountsService.synchronizeTransactions([account]); } } diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index 296585cc..032ad636 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -135,7 +135,7 @@ export class KeyringHandler implements Keyring { * a background event to sync them right after. */ await this.#snapClient.scheduleBackgroundEvent({ - method: BackgroundEventMethod.SyncAccountTransactions, + method: BackgroundEventMethod.SynchronizeAccountTransactions, params: { accountId: account.id }, duration: 'PT1S', }); From 5c74f2dc5bab35d7f5613c36696116d739cc82d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:58:14 +0200 Subject: [PATCH 039/238] 1.4.0 (#45) This is the release candidate for version 1.4.0. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 14 +++++++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 354f239a..805e710e 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] + +### Added + +- Implement account synchronization when transactions happen ([#38](https://github.com/MetaMask/snap-tron-wallet/pull/38)) +- Implement `computeFee` handler ([#40](https://github.com/MetaMask/snap-tron-wallet/pull/40)) + +### Changed + +- Return new required fields in KeyringAccount objects ([#41](https://github.com/MetaMask/snap-tron-wallet/pull/41)) + ## [1.2.0] ### Added @@ -59,7 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...v1.4.0 [1.2.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.3...v1.1.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 8ba1796a..1fdcae0b 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.2.0", + "version": "1.4.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", From 227b4bfdc4665ccacba0bce86d31124d875870ca Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 13 Oct 2025 11:42:48 +0200 Subject: [PATCH 040/238] Revert "1.4.0 (#45)" (#47) This reverts commit 5c74f2dc5bab35d7f5613c36696116d739cc82d0. --- merged-packages/tron-wallet-snap/CHANGELOG.md | 14 +------------- merged-packages/tron-wallet-snap/package.json | 2 +- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 805e710e..354f239a 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,17 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.4.0] - -### Added - -- Implement account synchronization when transactions happen ([#38](https://github.com/MetaMask/snap-tron-wallet/pull/38)) -- Implement `computeFee` handler ([#40](https://github.com/MetaMask/snap-tron-wallet/pull/40)) - -### Changed - -- Return new required fields in KeyringAccount objects ([#41](https://github.com/MetaMask/snap-tron-wallet/pull/41)) - ## [1.2.0] ### Added @@ -70,8 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.4.0...HEAD -[1.4.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...v1.4.0 +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...HEAD [1.2.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.3...v1.1.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 1fdcae0b..8ba1796a 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.4.0", + "version": "1.2.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", From c5ec93bff8c712252ce208a2e3144c4a69d31dbc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:08:40 +0100 Subject: [PATCH 041/238] 1.3.0 (#48) This is the release candidate for version 1.3.0. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 12 +++++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 5 ++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 354f239a..2c4e4436 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-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] +## [1.3.0] + +### Added + +- Add missing "sync transactions" background event ([#44](https://github.com/MetaMask/snap-tron-wallet/pull/44)) +- Implement account synchronization when transactions happen ([#38](https://github.com/MetaMask/snap-tron-wallet/pull/38)) +- Add new required fields to KeyringAccount objects ([#41](https://github.com/MetaMask/snap-tron-wallet/pull/41)) +- Implement `computeFee` handler ([#40](https://github.com/MetaMask/snap-tron-wallet/pull/40)) + ## [1.2.0] ### Added @@ -59,7 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.0.3...v1.1.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 8ba1796a..718f0c44 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.2.0", + "version": "1.3.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 3780464d..e81b33a0 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.2.0", + "version": "1.3.0", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "3kPqlK0NwV+/dJHpzd6PqVIW4LMdIeB/B1UOYF/POzY=", + "shasum": "J7VNFHw71IUsDMjB3iieYdX2v7rj9r97/Vm13VTZgzk=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -44,7 +44,6 @@ "curve": "secp256k1" } ], - "endowment:lifecycle-hooks": {}, "endowment:network-access": {}, "snap_manageAccounts": {}, "snap_manageState": {}, From 8cc791c3e5342c3aca54bf971d823322917473d5 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 13 Oct 2025 12:51:44 +0200 Subject: [PATCH 042/238] feat: send maximum Bandwidth and Energy as assets (#42) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/price-api/PriceApiClient.ts | 5 +- .../clients/token-api/TokenApiClient.test.ts | 2 +- .../src/clients/token-api/TokenApiClient.ts | 6 +- .../tron-wallet-snap/src/constants/index.ts | 344 +++++++++++++++--- .../tron-wallet-snap/src/entities/assets.ts | 27 +- .../handlers/clientRequest/clientRequest.ts | 7 + .../src/handlers/clientRequest/validation.ts | 3 +- .../src/services/accounts/AccountsService.ts | 2 +- .../src/services/assets/AssetsService.ts | 254 ++++++------- .../src/services/assets/types.ts | 22 +- .../src/validation/structs.ts | 18 + 12 files changed, 469 insertions(+), 223 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index e81b33a0..32c2e4d8 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "J7VNFHw71IUsDMjB3iieYdX2v7rj9r97/Vm13VTZgzk=", + "shasum": "rs7hNZqN4hsO/Obz9nW/8PvJhOmCXPEb2e2a5PxK64A=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts index 291e00ca..4fad9682 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts @@ -21,7 +21,7 @@ import { } from './types'; import type { ICache } from '../../caching/ICache'; import { useCache } from '../../caching/useCache'; -import { EXCLUDED_ASSET_SUFFIXES } from '../../constants'; +import { SPECIAL_ASSETS } from '../../constants'; import type { ConfigProvider } from '../../services/config'; import { buildUrl } from '../../utils/buildUrl'; import type { ILogger } from '../../utils/logger'; @@ -256,8 +256,7 @@ export class PriceApiClient { vsCurrency: VsCurrencyParam | string = 'usd', ): Promise { const filteredTokens = tokenCaip19Types.filter( - (token) => - !EXCLUDED_ASSET_SUFFIXES.some((suffix) => token.endsWith(suffix)), + (tokenCaip19Type) => !SPECIAL_ASSETS.includes(tokenCaip19Type), ); return this.#getMultipleSpotPrices_CACHE(filteredTokens, vsCurrency); diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts index b50d727e..db17b8b1 100644 --- a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts @@ -177,7 +177,7 @@ describe('TokenApiClient', () => { }); await expect(client.getTokensMetadata(tokenAddresses)).rejects.toThrow( - 'At path: 0.assetId -- Expected a string matching `/^tron:(728126428|3448148188|2494104990)\\/(trc10|trc20):[a-zA-Z0-9]+$/` but received "bad-asset-id"', + 'At path: 0.assetId -- Expected a string matching `/^tron:(728126428|3448148188|2494104990|localnet)\\/(trc10|trc20):[a-zA-Z0-9]+$/` but received "bad-asset-id"', ); }); diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts index facdc8f7..d2b47038 100644 --- a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts @@ -3,7 +3,7 @@ import { array, assert, type Infer } from '@metamask/superstruct'; import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; import { TokenMetadataResponseStruct } from './structs'; -import { EXCLUDED_ASSET_SUFFIXES, Network } from '../../constants'; +import { Network, SPECIAL_ASSETS } from '../../constants'; import type { TokenCaipAssetType } from '../../services/assets/types'; import { TokenCaipAssetTypeStruct } from '../../services/assets/types'; import type { ConfigProvider } from '../../services/config'; @@ -93,9 +93,7 @@ export class TokenApiClient { * Exclude TRON resource tokens (energy and bandwidth), staked tokens, and tokens not from supported networks. */ const supportedAssetTypes = assetTypes.filter((assetType) => { - if ( - EXCLUDED_ASSET_SUFFIXES.some((suffix) => assetType.endsWith(suffix)) - ) { + if (SPECIAL_ASSETS.includes(assetType)) { return false; } const { chainId } = parseCaipAssetType(assetType); diff --git a/merged-packages/tron-wallet-snap/src/constants/index.ts b/merged-packages/tron-wallet-snap/src/constants/index.ts index 52e7fca1..2a77c655 100644 --- a/merged-packages/tron-wallet-snap/src/constants/index.ts +++ b/merged-packages/tron-wallet-snap/src/constants/index.ts @@ -17,92 +17,285 @@ export enum KnownCaip19Id { UsdtMainnet = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, - // Tron Resource Assets + /** + * Staked Tron + */ + TrxStakedForBandwidthMainnet = `${Network.Mainnet}/slip44:195-staked-for-bandwidth`, + TrxStakedForBandwidthNile = `${Network.Nile}/slip44:195-staked-for-bandwidth`, + TrxStakedForBandwidthShasta = `${Network.Shasta}/slip44:195-staked-for-bandwidth`, + TrxStakedForBandwidthLocalnet = `${Network.Localnet}/slip44:195-staked-for-bandwidth`, + + TrxStakedForEnergyMainnet = `${Network.Mainnet}/slip44:195-staked-for-energy`, + TrxStakedForEnergyNile = `${Network.Nile}/slip44:195-staked-for-energy`, + TrxStakedForEnergyShasta = `${Network.Shasta}/slip44:195-staked-for-energy`, + TrxStakedForEnergyLocalnet = `${Network.Localnet}/slip44:195-staked-for-energy`, + + /** + * Tron Resource Assets + */ EnergyMainnet = `${Network.Mainnet}/slip44:energy`, EnergyNile = `${Network.Nile}/slip44:energy`, EnergyShasta = `${Network.Shasta}/slip44:energy`, EnergyLocalnet = `${Network.Localnet}/slip44:energy`, + MaximumEnergyMainnet = `${Network.Mainnet}/slip44:maximum-energy`, + MaximumEnergyNile = `${Network.Nile}/slip44:maximum-energy`, + MaximumEnergyShasta = `${Network.Shasta}/slip44:maximum-energy`, + MaximumEnergyLocalnet = `${Network.Localnet}/slip44:maximum-energy`, + BandwidthMainnet = `${Network.Mainnet}/slip44:bandwidth`, BandwidthNile = `${Network.Nile}/slip44:bandwidth`, BandwidthShasta = `${Network.Shasta}/slip44:bandwidth`, BandwidthLocalnet = `${Network.Localnet}/slip44:bandwidth`, + + MaximumBandwidthMainnet = `${Network.Mainnet}/slip44:maximum-bandwidth`, + MaximumBandwidthNile = `${Network.Nile}/slip44:maximum-bandwidth`, + MaximumBandwidthShasta = `${Network.Shasta}/slip44:maximum-bandwidth`, + MaximumBandwidthLocalnet = `${Network.Localnet}/slip44:maximum-bandwidth`, } +export const TRX_METADATA = { + fungible: true as const, + name: 'Tron', + symbol: 'TRX', + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'Tron', + symbol: 'TRX', + decimals: 6, + }, + ], +}; + +export const TRX_STAKED_FOR_BANDWIDTH_METADATA = { + name: 'Staked for Bandwidth', + symbol: 'sTRX-BANDWIDTH', + fungible: true as const, + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'sTRX-BANDWIDTH', + symbol: 'sTRX-BANDWIDTH', + decimals: 6, + }, + ], +}; + +export const TRX_STAKED_FOR_ENERGY_METADATA = { + name: 'Staked for Energy', + symbol: 'sTRX-ENERGY', + fungible: true as const, + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'sTRX-ENERGY', + symbol: 'sTRX-ENERGY', + decimals: 6, + }, + ], +}; + +export const BANDWIDTH_METADATA = { + name: 'Bandwidth', + symbol: 'BANDWIDTH', + fungible: true as const, + decimals: 0, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'Bandwidth', + symbol: 'BANDWIDTH', + decimals: 0, + }, + ], +}; + +export const MAX_BANDWIDTH_METADATA = { + name: 'Max Bandwidth', + symbol: 'MAX-BANDWIDTH', + fungible: true as const, + decimals: 0, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'Max Bandwidth', + symbol: 'MAX-BANDWIDTH', + decimals: 0, + }, + ], +}; + +export const ENERGY_METADATA = { + name: 'Energy', + symbol: 'ENERGY', + fungible: true as const, + decimals: 0, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'Energy', + symbol: 'ENERGY', + decimals: 0, + }, + ], +}; + +export const MAX_ENERGY_METADATA = { + name: 'Max Energy', + symbol: 'MAX-ENERGY', + fungible: true as const, + decimals: 0, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + units: [ + { + name: 'Max Energy', + symbol: 'MAX-ENERGY', + decimals: 0, + }, + ], +}; + export const TokenMetadata = { [KnownCaip19Id.TrxMainnet]: { id: KnownCaip19Id.TrxMainnet, - name: 'Tron', - symbol: 'TRX', - decimals: 6, + ...TRX_METADATA, }, [KnownCaip19Id.TrxNile]: { id: KnownCaip19Id.TrxNile, - name: 'Tron', - symbol: 'TRX', - decimals: 6, + ...TRX_METADATA, }, [KnownCaip19Id.TrxShasta]: { id: KnownCaip19Id.TrxShasta, - name: 'Tron', - symbol: 'TRX', - decimals: 6, + ...TRX_METADATA, }, [KnownCaip19Id.TrxLocalnet]: { id: KnownCaip19Id.TrxLocalnet, - name: 'Tron', - symbol: 'TRX', - decimals: 6, + ...TRX_METADATA, }, - // Energy resource metadata - [KnownCaip19Id.EnergyMainnet]: { - id: KnownCaip19Id.EnergyMainnet, - name: 'Tron Energy', - symbol: 'ENERGY', - decimals: 0, + /** + * Tron Staked for Bandwidth Metadata + */ + [KnownCaip19Id.TrxStakedForBandwidthMainnet]: { + id: KnownCaip19Id.TrxStakedForBandwidthMainnet, + ...TRX_STAKED_FOR_BANDWIDTH_METADATA, }, - [KnownCaip19Id.EnergyNile]: { - id: KnownCaip19Id.EnergyNile, - name: 'Tron Energy', - symbol: 'ENERGY', - decimals: 0, + [KnownCaip19Id.TrxStakedForBandwidthNile]: { + id: KnownCaip19Id.TrxStakedForBandwidthNile, + ...TRX_STAKED_FOR_BANDWIDTH_METADATA, }, - [KnownCaip19Id.EnergyShasta]: { - id: KnownCaip19Id.EnergyShasta, - name: 'Tron Energy', - symbol: 'ENERGY', - decimals: 0, + [KnownCaip19Id.TrxStakedForBandwidthShasta]: { + id: KnownCaip19Id.TrxStakedForBandwidthShasta, + ...TRX_STAKED_FOR_BANDWIDTH_METADATA, }, - [KnownCaip19Id.EnergyLocalnet]: { - id: KnownCaip19Id.EnergyLocalnet, - name: 'Tron Energy', - symbol: 'ENERGY', - decimals: 0, + [KnownCaip19Id.TrxStakedForBandwidthLocalnet]: { + id: KnownCaip19Id.TrxStakedForBandwidthLocalnet, + ...TRX_STAKED_FOR_BANDWIDTH_METADATA, + }, + /** + * Tron Staked for Energy Metadata + */ + [KnownCaip19Id.TrxStakedForEnergyMainnet]: { + id: KnownCaip19Id.TrxStakedForEnergyMainnet, + ...TRX_STAKED_FOR_ENERGY_METADATA, + }, + [KnownCaip19Id.TrxStakedForEnergyNile]: { + id: KnownCaip19Id.TrxStakedForEnergyNile, + ...TRX_STAKED_FOR_ENERGY_METADATA, }, - // Bandwidth resource metadata + [KnownCaip19Id.TrxStakedForEnergyShasta]: { + id: KnownCaip19Id.TrxStakedForEnergyShasta, + ...TRX_STAKED_FOR_ENERGY_METADATA, + }, + [KnownCaip19Id.TrxStakedForEnergyLocalnet]: { + id: KnownCaip19Id.TrxStakedForEnergyLocalnet, + ...TRX_STAKED_FOR_ENERGY_METADATA, + }, + /** + * Bandwidth Resource Metadata + */ [KnownCaip19Id.BandwidthMainnet]: { id: KnownCaip19Id.BandwidthMainnet, - name: 'Tron Bandwidth', - symbol: 'BANDWIDTH', - decimals: 0, + ...BANDWIDTH_METADATA, }, [KnownCaip19Id.BandwidthNile]: { id: KnownCaip19Id.BandwidthNile, - name: 'Tron Bandwidth', - symbol: 'BANDWIDTH', - decimals: 0, + ...BANDWIDTH_METADATA, }, [KnownCaip19Id.BandwidthShasta]: { id: KnownCaip19Id.BandwidthShasta, - name: 'Tron Bandwidth', - symbol: 'BANDWIDTH', - decimals: 0, + ...BANDWIDTH_METADATA, }, [KnownCaip19Id.BandwidthLocalnet]: { id: KnownCaip19Id.BandwidthLocalnet, - name: 'Tron Bandwidth', - symbol: 'BANDWIDTH', - decimals: 0, + ...BANDWIDTH_METADATA, + }, + /** + * Max Bandwidth Metadata + */ + [KnownCaip19Id.MaximumBandwidthMainnet]: { + id: KnownCaip19Id.MaximumBandwidthMainnet, + ...MAX_BANDWIDTH_METADATA, + }, + [KnownCaip19Id.MaximumBandwidthNile]: { + id: KnownCaip19Id.MaximumBandwidthNile, + ...MAX_BANDWIDTH_METADATA, + }, + [KnownCaip19Id.MaximumBandwidthShasta]: { + id: KnownCaip19Id.MaximumBandwidthShasta, + ...MAX_BANDWIDTH_METADATA, + }, + [KnownCaip19Id.MaximumBandwidthLocalnet]: { + id: KnownCaip19Id.MaximumBandwidthLocalnet, + ...MAX_BANDWIDTH_METADATA, + }, + /** + * Energy Resource Metadata + */ + [KnownCaip19Id.EnergyMainnet]: { + id: KnownCaip19Id.EnergyMainnet, + ...ENERGY_METADATA, + }, + [KnownCaip19Id.EnergyNile]: { + id: KnownCaip19Id.EnergyNile, + ...ENERGY_METADATA, + }, + [KnownCaip19Id.EnergyShasta]: { + id: KnownCaip19Id.EnergyShasta, + ...ENERGY_METADATA, + }, + [KnownCaip19Id.EnergyLocalnet]: { + id: KnownCaip19Id.EnergyLocalnet, + ...ENERGY_METADATA, + }, + /** + * Max Energy Metadata + */ + [KnownCaip19Id.MaximumEnergyMainnet]: { + id: KnownCaip19Id.MaximumEnergyMainnet, + ...MAX_ENERGY_METADATA, + }, + [KnownCaip19Id.MaximumEnergyNile]: { + id: KnownCaip19Id.MaximumEnergyNile, + ...MAX_ENERGY_METADATA, + }, + [KnownCaip19Id.MaximumEnergyShasta]: { + id: KnownCaip19Id.EnergyShasta, + ...MAX_ENERGY_METADATA, + }, + [KnownCaip19Id.MaximumEnergyLocalnet]: { + id: KnownCaip19Id.EnergyLocalnet, + ...MAX_ENERGY_METADATA, }, } as const; @@ -112,38 +305,77 @@ export const Networks = { cluster: 'mainnet', name: 'Tron Mainnet', nativeToken: TokenMetadata[KnownCaip19Id.TrxMainnet], - energy: TokenMetadata[KnownCaip19Id.EnergyMainnet], + stakedForBandwidth: + TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthMainnet], + stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyMainnet], bandwidth: TokenMetadata[KnownCaip19Id.BandwidthMainnet], + maximumBandwidth: TokenMetadata[KnownCaip19Id.MaximumBandwidthMainnet], + energy: TokenMetadata[KnownCaip19Id.EnergyMainnet], + maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyMainnet], }, [Network.Nile]: { caip2Id: Network.Nile, cluster: 'devnet', name: 'Tron Nile', nativeToken: TokenMetadata[KnownCaip19Id.TrxNile], - energy: TokenMetadata[KnownCaip19Id.EnergyNile], + stakedForBandwidth: TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthNile], + stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyNile], bandwidth: TokenMetadata[KnownCaip19Id.BandwidthNile], + maximumBandwidth: TokenMetadata[KnownCaip19Id.MaximumBandwidthNile], + energy: TokenMetadata[KnownCaip19Id.EnergyNile], + maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyNile], }, [Network.Shasta]: { caip2Id: Network.Shasta, cluster: 'testnet', name: 'Tron Shasta', nativeToken: TokenMetadata[KnownCaip19Id.TrxShasta], - energy: TokenMetadata[KnownCaip19Id.EnergyShasta], + stakedForBandwidth: + TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthShasta], + stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyShasta], bandwidth: TokenMetadata[KnownCaip19Id.BandwidthShasta], + maximumBandwidth: TokenMetadata[KnownCaip19Id.BandwidthShasta], + energy: TokenMetadata[KnownCaip19Id.EnergyShasta], + maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyShasta], }, [Network.Localnet]: { caip2Id: Network.Localnet, cluster: 'local', name: 'Tron Localnet', nativeToken: TokenMetadata[KnownCaip19Id.TrxLocalnet], - energy: TokenMetadata[KnownCaip19Id.EnergyLocalnet], + stakedForBandwidth: + TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthLocalnet], + stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyLocalnet], bandwidth: TokenMetadata[KnownCaip19Id.BandwidthLocalnet], + maximumBandwidth: TokenMetadata[KnownCaip19Id.BandwidthLocalnet], + energy: TokenMetadata[KnownCaip19Id.EnergyLocalnet], + maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyLocalnet], }, } as const; -export const EXCLUDED_ASSET_SUFFIXES = [ - '/slip44:energy', - '/slip44:bandwidth', - '/slip44:195-staked-for-energy', - '/slip44:195-staked-for-bandwidth', +export const SPECIAL_ASSETS: string[] = [ + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForBandwidthNile, + KnownCaip19Id.TrxStakedForBandwidthShasta, + KnownCaip19Id.TrxStakedForBandwidthLocalnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxStakedForEnergyNile, + KnownCaip19Id.TrxStakedForEnergyShasta, + KnownCaip19Id.TrxStakedForEnergyLocalnet, + KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.BandwidthNile, + KnownCaip19Id.BandwidthShasta, + KnownCaip19Id.BandwidthLocalnet, + KnownCaip19Id.MaximumBandwidthMainnet, + KnownCaip19Id.MaximumBandwidthNile, + KnownCaip19Id.MaximumBandwidthShasta, + KnownCaip19Id.MaximumBandwidthLocalnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.EnergyNile, + KnownCaip19Id.EnergyShasta, + KnownCaip19Id.EnergyLocalnet, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.MaximumEnergyNile, + KnownCaip19Id.MaximumEnergyShasta, + KnownCaip19Id.MaximumEnergyLocalnet, ]; diff --git a/merged-packages/tron-wallet-snap/src/entities/assets.ts b/merged-packages/tron-wallet-snap/src/entities/assets.ts index 64b9bacd..fcddbf46 100644 --- a/merged-packages/tron-wallet-snap/src/entities/assets.ts +++ b/merged-packages/tron-wallet-snap/src/entities/assets.ts @@ -1,4 +1,4 @@ -import type { Network } from '../constants'; +import type { KnownCaip19Id, Network } from '../constants'; import type { NativeCaipAssetType, NftCaipAssetType, @@ -24,7 +24,27 @@ export type StakedAsset = BaseAsset & { }; export type ResourceAsset = BaseAsset & { - assetType: `${Network}/slip44:energy` | `${Network}/slip44:bandwidth`; + assetType: + | KnownCaip19Id.EnergyMainnet + | KnownCaip19Id.EnergyNile + | KnownCaip19Id.EnergyShasta + | KnownCaip19Id.EnergyLocalnet + | KnownCaip19Id.BandwidthMainnet + | KnownCaip19Id.BandwidthNile + | KnownCaip19Id.BandwidthShasta + | KnownCaip19Id.BandwidthLocalnet; +}; + +export type MaximumResourceAsset = BaseAsset & { + assetType: + | KnownCaip19Id.MaximumEnergyMainnet + | KnownCaip19Id.MaximumEnergyNile + | KnownCaip19Id.MaximumEnergyShasta + | KnownCaip19Id.MaximumEnergyLocalnet + | KnownCaip19Id.MaximumBandwidthMainnet + | KnownCaip19Id.MaximumBandwidthNile + | KnownCaip19Id.MaximumBandwidthShasta + | KnownCaip19Id.MaximumBandwidthLocalnet; }; export type TokenAsset = BaseAsset & { @@ -40,4 +60,5 @@ export type AssetEntity = | StakedAsset | TokenAsset | NftAsset - | ResourceAsset; + | ResourceAsset + | MaximumResourceAsset; diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 2a4c0af3..94a67408 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -243,7 +243,14 @@ export class ClientRequestHandler { const accountAssets = await this.#assetsService.getByKeyringAccountId(accountId); + + /** + * Typescript is not smart enough to infer that the validation above + * guarantees that assetId is a valid TronCaipAssetTypeStruct and the + * unsafe enum comparison is irrelevant. + */ const asset = accountAssets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison (assetItem) => assetItem.assetType === assetId, ); diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index ec618991..61658698 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -14,6 +14,7 @@ import { PositiveNumberStringStruct, ScopeStringStruct, TronAddressStruct, + TronCaipAssetTypeStruct, UuidStruct, } from '../../validation/structs'; @@ -70,7 +71,7 @@ export const OnAddressInputRequestStruct = object({ export const OnAmountInputRequestParamsStruct = object({ value: PositiveNumberStringStruct, accountId: UuidStruct, - assetId: CaipAssetTypeStruct, + assetId: TronCaipAssetTypeStruct, }); export const OnAmountInputRequestStruct = object({ diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index e0a0d651..36a7fe0b 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -166,7 +166,7 @@ export class AccountsService { derivationPath, groupIndex: accountIndex, }, - exportable: false, + exportable: true, }, methods: ['signMessageV2', 'verifyMessageV2'], }; diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index ebcc1593..747a9d09 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -31,9 +31,17 @@ import type { AccountResources } from '../../clients/tron-http'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; import type { TronAccount } from '../../clients/trongrid/types'; -import { Networks, type Network } from '../../constants'; +import { + BANDWIDTH_METADATA, + ENERGY_METADATA, + Networks, + TRX_METADATA, + TRX_STAKED_FOR_BANDWIDTH_METADATA, + TRX_STAKED_FOR_ENERGY_METADATA, + type Network, +} from '../../constants'; import { configProvider } from '../../context'; -import type { AssetEntity, ResourceAsset } from '../../entities/assets'; +import type { AssetEntity } from '../../entities/assets'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; import type { State, UnencryptedStateValue } from '../state/State'; @@ -119,12 +127,12 @@ export class AssetsService { scope, tronAccountInfo, }); - const energyAsset = this.#extractEnergy({ + const bandwidthAssets = this.#extractBandwidth({ account, scope, tronAccountResources, }); - const bandwidthAsset = this.#extractBandwidth({ + const energyAssets = this.#extractEnergy({ account, scope, tronAccountResources, @@ -142,19 +150,19 @@ export class AssetsService { const assetTypes = [ nativeAsset.assetType, - ...stakedNativeAssets.map((stakedAsset) => stakedAsset.assetType), - energyAsset.assetType, - bandwidthAsset.assetType, - ...trc10Assets.map((tokenAsset) => tokenAsset.assetType), - ...trc20Assets.map((tokenAsset) => tokenAsset.assetType), + ...stakedNativeAssets.map((assets) => assets.assetType), + ...bandwidthAssets.map((assets) => assets.assetType), + ...energyAssets.map((assets) => assets.assetType), + ...trc10Assets.map((assets) => assets.assetType), + ...trc20Assets.map((assets) => assets.assetType), ]; const assetsMetadata = await this.getAssetsMetadata(assetTypes); const assets = [ nativeAsset, ...stakedNativeAssets, - energyAsset, - bandwidthAsset, + ...bandwidthAssets, + ...energyAssets, ...trc10Assets, ...trc20Assets, ].map((asset) => { @@ -191,14 +199,14 @@ export class AssetsService { tronAccountInfo: TronAccount; }): AssetEntity { const asset: AssetEntity = { - assetType: `${scope}/slip44:195` as NativeCaipAssetType, + assetType: Networks[scope].nativeToken.id, keyringAccountId: account.id, network: scope, - symbol: 'TRX', - decimals: 6, + symbol: Networks[scope].nativeToken.symbol, + decimals: Networks[scope].nativeToken.decimals, rawAmount: tronAccountInfo.balance.toString(), uiAmount: new BigNumber(tronAccountInfo.balance) - .dividedBy(10 ** 6) + .dividedBy(10 ** Networks[scope].nativeToken.decimals) .toString(), }; @@ -216,8 +224,8 @@ export class AssetsService { }): AssetEntity[] { const assets: AssetEntity[] = []; - let stakedEnergyAmount = 0; let stakedBandwidthAmount = 0; + let stakedEnergyAmount = 0; tronAccountInfo.frozenV2?.forEach((frozen) => { const amount = frozen.amount ?? 0; @@ -230,41 +238,40 @@ export class AssetsService { } }); - if (stakedEnergyAmount > 0) { - const stakedEnergyAsset: AssetEntity = { - assetType: `${scope}/slip44:195-staked-for-energy` as const, + if (stakedBandwidthAmount > 0) { + const stakedBandwidthAsset: AssetEntity = { + assetType: Networks[scope].stakedForBandwidth.id, keyringAccountId: account.id, network: scope, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: stakedEnergyAmount.toString(), - uiAmount: new BigNumber(stakedEnergyAmount) - .dividedBy(10 ** 6) + symbol: Networks[scope].stakedForBandwidth.symbol, + decimals: Networks[scope].stakedForBandwidth.decimals, + rawAmount: stakedBandwidthAmount.toString(), + uiAmount: new BigNumber(stakedBandwidthAmount) + .dividedBy(10 ** Networks[scope].stakedForBandwidth.decimals) .toString(), }; - assets.push(stakedEnergyAsset); + assets.push(stakedBandwidthAsset); } - if (stakedBandwidthAmount > 0) { - const stakedBandwidthAsset: AssetEntity = { - assetType: - `${scope}/slip44:195-staked-for-bandwidth` as NativeCaipAssetType, + if (stakedEnergyAmount > 0) { + const stakedEnergyAsset: AssetEntity = { + assetType: Networks[scope].stakedForEnergy.id, keyringAccountId: account.id, network: scope, - symbol: 'sTRX-BANDWIDTH', - decimals: 6, - rawAmount: stakedBandwidthAmount.toString(), - uiAmount: new BigNumber(stakedBandwidthAmount) - .dividedBy(10 ** 6) + symbol: Networks[scope].stakedForEnergy.symbol, + decimals: Networks[scope].stakedForEnergy.decimals, + rawAmount: stakedEnergyAmount.toString(), + uiAmount: new BigNumber(stakedEnergyAmount) + .dividedBy(10 ** Networks[scope].stakedForBandwidth.decimals) .toString(), }; - assets.push(stakedBandwidthAsset); + assets.push(stakedEnergyAsset); } return assets; } - #extractEnergy({ + #extractBandwidth({ account, scope, tronAccountResources, @@ -272,23 +279,34 @@ export class AssetsService { account: KeyringAccount; scope: Network; tronAccountResources: AccountResources; - }): AssetEntity { - const energy = tronAccountResources.EnergyLimit; - - const asset: ResourceAsset = { - assetType: Networks[scope].energy.id, - keyringAccountId: account.id, - network: scope, - symbol: 'ENERGY', - decimals: 0, - rawAmount: energy.toString(), - uiAmount: energy.toString(), - }; + }): AssetEntity[] { + const bandwidth = + tronAccountResources.freeNetLimit + tronAccountResources.NetLimit; + const maximumBandwidth = tronAccountResources.TotalNetLimit; - return asset; + return [ + { + assetType: Networks[scope].bandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].bandwidth.id, + decimals: Networks[scope].bandwidth.decimals, + rawAmount: bandwidth.toString(), + uiAmount: bandwidth.toString(), + }, + { + assetType: Networks[scope].maximumBandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].maximumBandwidth.id, + decimals: Networks[scope].maximumBandwidth.decimals, + rawAmount: maximumBandwidth.toString(), + uiAmount: maximumBandwidth.toString(), + }, + ]; } - #extractBandwidth({ + #extractEnergy({ account, scope, tronAccountResources, @@ -296,21 +314,30 @@ export class AssetsService { account: KeyringAccount; scope: Network; tronAccountResources: AccountResources; - }): AssetEntity { - const bandwidth = - tronAccountResources.freeNetLimit + tronAccountResources.NetLimit; - - const asset: ResourceAsset = { - assetType: Networks[scope].bandwidth.id, - keyringAccountId: account.id, - network: scope, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: bandwidth.toString(), - uiAmount: bandwidth.toString(), - }; + }): AssetEntity[] { + const energy = tronAccountResources.EnergyLimit; + const maximumEnergy = tronAccountResources.TotalEnergyLimit; - return asset; + return [ + { + assetType: Networks[scope].energy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].energy.symbol, + decimals: Networks[scope].energy.decimals, + rawAmount: energy.toString(), + uiAmount: energy.toString(), + }, + { + assetType: Networks[scope].energy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].maximumEnergy.symbol, + decimals: Networks[scope].maximumEnergy.decimals, + rawAmount: maximumEnergy.toString(), + uiAmount: maximumEnergy.toString(), + }, + ]; } #extractTrc10Assets({ @@ -466,22 +493,7 @@ export class AssetsService { > = {}; for (const assetType of assetTypes) { - // const { chainId } = parseCaipAssetType(assetType); - nativeTokensMetadata[assetType] = { - name: 'Tron', - symbol: 'TRX', - fungible: true as const, - // iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/tron/${chainId}/slip44/195.png`, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'Tron', - symbol: 'TRX', - decimals: 6, - }, - ], - }; + nativeTokensMetadata[assetType] = TRX_METADATA; } return nativeTokensMetadata; @@ -490,109 +502,57 @@ export class AssetsService { #getStakedTokensMetadata( assetTypes: StakedCaipAssetType[], ): Record { - // Can either be Staked for Energy or Staked for Bandwidth + // Can either be Staked for Bandwidth or Staked for Energy const stakedTokensMetadata: Record< CaipAssetType, FungibleAssetMetadata | null > = {}; for (const assetType of assetTypes) { - const isForEnergy = assetType.endsWith('staked-for-energy'); + const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); - if (isForEnergy) { - stakedTokensMetadata[assetType] = { - name: 'Staked for Energy', - symbol: 'sTRX-ENERGY', - fungible: true as const, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'sTRX-ENERGY', - symbol: 'sTRX-ENERGY', - decimals: 6, - }, - ], - }; + if (isForBandwdidth) { + stakedTokensMetadata[assetType] = TRX_STAKED_FOR_BANDWIDTH_METADATA; } - const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); + const isForEnergy = assetType.endsWith('staked-for-energy'); - if (isForBandwdidth) { - stakedTokensMetadata[assetType] = { - name: 'Staked for Bandwidth', - symbol: 'sTRX-BANDWIDTH', - fungible: true as const, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'sTRX-BANDWIDTH', - symbol: 'sTRX-BANDWIDTH', - decimals: 6, - }, - ], - }; + if (isForEnergy) { + stakedTokensMetadata[assetType] = TRX_STAKED_FOR_ENERGY_METADATA; } } return stakedTokensMetadata; } - #getEnergyMetadata( + #getBandwidthMetadata( assetTypes: ResourceCaipAssetType[], ): Record { - const energyTokensMetadata: Record< + const bandwidthTokensMetadata: Record< CaipAssetType, FungibleAssetMetadata | null > = {}; for (const assetType of assetTypes) { - energyTokensMetadata[assetType] = { - name: 'Energy', - symbol: 'ENERGY', - fungible: true as const, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'Energy', - symbol: 'ENERGY', - decimals: 0, - }, - ], - }; + bandwidthTokensMetadata[assetType] = BANDWIDTH_METADATA; } - return energyTokensMetadata; + return bandwidthTokensMetadata; } - #getBandwidthMetadata( + #getEnergyMetadata( assetTypes: ResourceCaipAssetType[], ): Record { - const bandwidthTokensMetadata: Record< + const energyTokensMetadata: Record< CaipAssetType, FungibleAssetMetadata | null > = {}; for (const assetType of assetTypes) { - bandwidthTokensMetadata[assetType] = { - name: 'Bandwidth', - symbol: 'BANDWIDTH', - fungible: true as const, - iconUrl: - 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'Bandwidth', - symbol: 'BANDWIDTH', - decimals: 0, - }, - ], - }; + energyTokensMetadata[assetType] = ENERGY_METADATA; } - return bandwidthTokensMetadata; + return energyTokensMetadata; } async #getTokensMetadata( diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts index 44e98100..2fe5cd28 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -1,7 +1,9 @@ import type { TrxScope } from '@metamask/keyring-api'; import { pattern, string } from '@metamask/superstruct'; -export type NativeCaipAssetType = `${TrxScope}/slip44:195`; +import type { Network } from '../../constants'; + +export type NativeCaipAssetType = `${Network}/slip44:195`; export type StakedCaipAssetType = `${TrxScope}/slip44:195-staked-for-${'energy' | 'bandwidth'}`; export type ResourceCaipAssetType = @@ -14,7 +16,7 @@ export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; */ export const NativeCaipAssetTypeStruct = pattern( string(), - /^tron:(728126428|3448148188|2494104990)\/slip44:195$/u, + /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:195$/u, ); /** @@ -22,7 +24,7 @@ export const NativeCaipAssetTypeStruct = pattern( */ export const StakedCaipAssetTypeStruct = pattern( string(), - /^tron:(728126428|3448148188|2494104990)\/slip44:195-staked-for-(energy|bandwidth)$/u, + /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:195-staked-for-(energy|bandwidth)$/u, ); /** @@ -30,7 +32,15 @@ export const StakedCaipAssetTypeStruct = pattern( */ export const ResourceCaipAssetTypeStruct = pattern( string(), - /^tron:(728126428|3448148188|2494104990)\/slip44:(energy|bandwidth)$/u, + /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:(energy|bandwidth)$/u, +); + +/** + * Validates a TRON maximum resource CAIP-19 ID (e.g., "tron:728126428/slip44:maximum-energy") + */ +export const MaximumResourceCaipAssetTypeStruct = pattern( + string(), + /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:maximum-(energy|bandwidth)$/u, ); /** @@ -38,7 +48,7 @@ export const ResourceCaipAssetTypeStruct = pattern( */ export const TokenCaipAssetTypeStruct = pattern( string(), - /^tron:(728126428|3448148188|2494104990)\/(trc10|trc20):[a-zA-Z0-9]+$/u, + /^tron:(728126428|3448148188|2494104990|localnet)\/(trc10|trc20):[a-zA-Z0-9]+$/u, ); /** @@ -46,5 +56,5 @@ export const TokenCaipAssetTypeStruct = pattern( */ export const NftCaipAssetTypeStruct = pattern( string(), - /^tron:(728126428|3448148188|2494104990)\/trc721:[a-zA-Z0-9]+$/u, + /^tron:(728126428|3448148188|2494104990|localnet)\/trc721:[a-zA-Z0-9]+$/u, ); diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.ts b/merged-packages/tron-wallet-snap/src/validation/structs.ts index 1fa86984..44cd0a93 100644 --- a/merged-packages/tron-wallet-snap/src/validation/structs.ts +++ b/merged-packages/tron-wallet-snap/src/validation/structs.ts @@ -12,10 +12,19 @@ import { record, refine, string, + union, } from '@metamask/superstruct'; import { TronWeb } from 'tronweb'; import { Network } from '../constants'; +import { + MaximumResourceCaipAssetTypeStruct, + NativeCaipAssetTypeStruct, + NftCaipAssetTypeStruct, + ResourceCaipAssetTypeStruct, + StakedCaipAssetTypeStruct, + TokenCaipAssetTypeStruct, +} from '../services/assets/types'; // create a uuid validation export const UuidStruct = pattern( @@ -329,3 +338,12 @@ export const TronAddressStruct: Struct = define( return true; }, ); + +export const TronCaipAssetTypeStruct: Struct = union([ + NativeCaipAssetTypeStruct, + StakedCaipAssetTypeStruct, + TokenCaipAssetTypeStruct, + NftCaipAssetTypeStruct, + ResourceCaipAssetTypeStruct, + MaximumResourceCaipAssetTypeStruct, +]); From 9601ab8936804c96389024d5b791f4b2fe5a099d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Wed, 15 Oct 2025 16:58:11 +0100 Subject: [PATCH 043/238] chore: edit names and balance decimals (#49) - Always return the default native asset. - Miscellaneous fixes - Pass the correct metadata - Fix extractTrc10Assets and how the assets were handled --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/tron-http/types.ts | 9 +- .../tron-wallet-snap/src/constants/index.ts | 57 +----- .../src/services/assets/AssetsService.ts | 193 +++++++++++++----- 4 files changed, 154 insertions(+), 107 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 32c2e4d8..118ef295 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "rs7hNZqN4hsO/Obz9nW/8PvJhOmCXPEb2e2a5PxK64A=", + "shasum": "6gSWouRP2LldHjb6jdToHigEHUt0q/VEQZJYPkUmJKw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts index 0efa61d3..a6520b45 100644 --- a/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts @@ -92,14 +92,21 @@ export type TRC10TokenMetadata = { decimals: number; }; +/** + * Account resources + * + * @see https://developers.tron.network/reference/getaccountresource + */ export type AccountResources = { + freeNetUsed: number; freeNetLimit: number; NetLimit: number; TotalNetLimit: number; TotalNetWeight: number; tronPowerUsed: number; tronPowerLimit: number; - EnergyLimit: number; + EnergyUsed?: number; + EnergyLimit?: number; TotalEnergyLimit: number; TotalEnergyWeight: number; }; diff --git a/merged-packages/tron-wallet-snap/src/constants/index.ts b/merged-packages/tron-wallet-snap/src/constants/index.ts index 2a77c655..d8fc5873 100644 --- a/merged-packages/tron-wallet-snap/src/constants/index.ts +++ b/merged-packages/tron-wallet-snap/src/constants/index.ts @@ -61,13 +61,6 @@ export const TRX_METADATA = { decimals: 6, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'Tron', - symbol: 'TRX', - decimals: 6, - }, - ], }; export const TRX_STAKED_FOR_BANDWIDTH_METADATA = { @@ -77,13 +70,6 @@ export const TRX_STAKED_FOR_BANDWIDTH_METADATA = { decimals: 6, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'sTRX-BANDWIDTH', - symbol: 'sTRX-BANDWIDTH', - decimals: 6, - }, - ], }; export const TRX_STAKED_FOR_ENERGY_METADATA = { @@ -93,13 +79,6 @@ export const TRX_STAKED_FOR_ENERGY_METADATA = { decimals: 6, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'sTRX-ENERGY', - symbol: 'sTRX-ENERGY', - decimals: 6, - }, - ], }; export const BANDWIDTH_METADATA = { @@ -109,13 +88,6 @@ export const BANDWIDTH_METADATA = { decimals: 0, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'Bandwidth', - symbol: 'BANDWIDTH', - decimals: 0, - }, - ], }; export const MAX_BANDWIDTH_METADATA = { @@ -125,13 +97,6 @@ export const MAX_BANDWIDTH_METADATA = { decimals: 0, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'Max Bandwidth', - symbol: 'MAX-BANDWIDTH', - decimals: 0, - }, - ], }; export const ENERGY_METADATA = { @@ -141,13 +106,6 @@ export const ENERGY_METADATA = { decimals: 0, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'Energy', - symbol: 'ENERGY', - decimals: 0, - }, - ], }; export const MAX_ENERGY_METADATA = { @@ -157,13 +115,6 @@ export const MAX_ENERGY_METADATA = { decimals: 0, iconUrl: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', - units: [ - { - name: 'Max Energy', - symbol: 'MAX-ENERGY', - decimals: 0, - }, - ], }; export const TokenMetadata = { @@ -290,11 +241,11 @@ export const TokenMetadata = { ...MAX_ENERGY_METADATA, }, [KnownCaip19Id.MaximumEnergyShasta]: { - id: KnownCaip19Id.EnergyShasta, + id: KnownCaip19Id.MaximumEnergyShasta, ...MAX_ENERGY_METADATA, }, [KnownCaip19Id.MaximumEnergyLocalnet]: { - id: KnownCaip19Id.EnergyLocalnet, + id: KnownCaip19Id.MaximumEnergyLocalnet, ...MAX_ENERGY_METADATA, }, } as const; @@ -334,7 +285,7 @@ export const Networks = { TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthShasta], stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyShasta], bandwidth: TokenMetadata[KnownCaip19Id.BandwidthShasta], - maximumBandwidth: TokenMetadata[KnownCaip19Id.BandwidthShasta], + maximumBandwidth: TokenMetadata[KnownCaip19Id.MaximumBandwidthShasta], energy: TokenMetadata[KnownCaip19Id.EnergyShasta], maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyShasta], }, @@ -347,7 +298,7 @@ export const Networks = { TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthLocalnet], stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyLocalnet], bandwidth: TokenMetadata[KnownCaip19Id.BandwidthLocalnet], - maximumBandwidth: TokenMetadata[KnownCaip19Id.BandwidthLocalnet], + maximumBandwidth: TokenMetadata[KnownCaip19Id.MaximumBandwidthLocalnet], energy: TokenMetadata[KnownCaip19Id.EnergyLocalnet], maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyLocalnet], }, diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 747a9d09..f70fe2fc 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -112,40 +112,58 @@ export class AssetsService { scope, }); - const [tronAccountInfo, tronAccountResources] = await Promise.all([ - this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), - this.#tronHttpClient.getAccountResources(scope, account.address), - ]); + const [tronAccountInfoRequest, tronAccountResourcesRequest] = + await Promise.allSettled([ + this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), + this.#tronHttpClient.getAccountResources(scope, account.address), + ]); + + if ( + tronAccountInfoRequest.status === 'rejected' || + tronAccountResourcesRequest.status === 'rejected' + ) { + return [ + { + symbol: Networks[scope].nativeToken.symbol, + decimals: Networks[scope].nativeToken.decimals, + uiAmount: '0', + assetType: Networks[scope].nativeToken.id, + keyringAccountId: account.id, + network: scope, + rawAmount: '0', + }, + ]; + } const nativeAsset = this.#extractNativeAsset({ account, scope, - tronAccountInfo, + tronAccountInfo: tronAccountInfoRequest.value, }); const stakedNativeAssets = this.#extractStakedNativeAssets({ account, scope, - tronAccountInfo, + tronAccountInfo: tronAccountInfoRequest.value, }); const bandwidthAssets = this.#extractBandwidth({ account, scope, - tronAccountResources, + tronAccountResources: tronAccountResourcesRequest.value, }); const energyAssets = this.#extractEnergy({ account, scope, - tronAccountResources, + tronAccountResources: tronAccountResourcesRequest.value, }); const trc10Assets = this.#extractTrc10Assets({ account, scope, - tronAccountInfo, + tronAccountInfo: tronAccountInfoRequest.value, }); const trc20Assets = this.#extractTrc20Assets({ account, scope, - tronAccountInfo, + tronAccountInfo: tronAccountInfoRequest.value, }); const assetTypes = [ @@ -166,24 +184,33 @@ export class AssetsService { ...trc10Assets, ...trc20Assets, ].map((asset) => { - const metadata = assetsMetadata[asset.assetType]; - const mergedAsset = { - ...asset, - ...metadata, - } as any; - - const unit = metadata?.fungible ? metadata.units?.[0] : undefined; - - if (unit) { - const decimals = new BigNumber(mergedAsset.rawAmount).dividedBy( - new BigNumber(10).pow(unit.decimals), - ); - mergedAsset.uiAmount = decimals.toString(); - } else { - mergedAsset.uiAmount = '0'; + const metadata = assetsMetadata[ + asset.assetType + ] as FungibleAssetMetadata | null; + + let { symbol } = asset; + let decimals = asset.decimals ?? 0; + + if (metadata?.fungible) { + const unit = metadata.units?.[0]; + if (unit) { + symbol = unit.symbol ?? metadata.symbol ?? symbol; + decimals = unit.decimals ?? decimals; + } else { + symbol = metadata?.symbol ?? symbol; + } } - return mergedAsset; + const uiAmount = new BigNumber(asset.rawAmount) + .dividedBy(new BigNumber(10).pow(decimals)) + .toString(); + + return { + ...asset, + symbol, + decimals, + uiAmount, + }; }); return assets; @@ -262,7 +289,7 @@ export class AssetsService { decimals: Networks[scope].stakedForEnergy.decimals, rawAmount: stakedEnergyAmount.toString(), uiAmount: new BigNumber(stakedEnergyAmount) - .dividedBy(10 ** Networks[scope].stakedForBandwidth.decimals) + .dividedBy(10 ** Networks[scope].stakedForEnergy.decimals) .toString(), }; assets.push(stakedEnergyAsset); @@ -278,18 +305,20 @@ export class AssetsService { }: { account: KeyringAccount; scope: Network; - tronAccountResources: AccountResources; + tronAccountResources: AccountResources | Record; }): AssetEntity[] { + const maximumBandwidth = + (tronAccountResources?.freeNetLimit ?? 0) + + (tronAccountResources?.NetLimit ?? 0); const bandwidth = - tronAccountResources.freeNetLimit + tronAccountResources.NetLimit; - const maximumBandwidth = tronAccountResources.TotalNetLimit; + maximumBandwidth - (tronAccountResources?.freeNetUsed ?? 0); return [ { assetType: Networks[scope].bandwidth.id, keyringAccountId: account.id, network: scope, - symbol: Networks[scope].bandwidth.id, + symbol: Networks[scope].bandwidth.symbol, decimals: Networks[scope].bandwidth.decimals, rawAmount: bandwidth.toString(), uiAmount: bandwidth.toString(), @@ -298,7 +327,7 @@ export class AssetsService { assetType: Networks[scope].maximumBandwidth.id, keyringAccountId: account.id, network: scope, - symbol: Networks[scope].maximumBandwidth.id, + symbol: Networks[scope].maximumBandwidth.symbol, decimals: Networks[scope].maximumBandwidth.decimals, rawAmount: maximumBandwidth.toString(), uiAmount: maximumBandwidth.toString(), @@ -313,10 +342,10 @@ export class AssetsService { }: { account: KeyringAccount; scope: Network; - tronAccountResources: AccountResources; + tronAccountResources: AccountResources | Record; }): AssetEntity[] { - const energy = tronAccountResources.EnergyLimit; - const maximumEnergy = tronAccountResources.TotalEnergyLimit; + const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; + const energy = tronAccountResources?.EnergyUsed ?? maximumEnergy; return [ { @@ -329,7 +358,7 @@ export class AssetsService { uiAmount: energy.toString(), }, { - assetType: Networks[scope].energy.id, + assetType: Networks[scope].maximumEnergy.id, keyringAccountId: account.id, network: scope, symbol: Networks[scope].maximumEnergy.symbol, @@ -353,17 +382,17 @@ export class AssetsService { const trc10Assets = assetV2?.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc10:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - }; - }); + // assetV2 has structure: { "key": "token_id", "value": "balance" } + // Each object in the array has "key" and "value" properties + return { + assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + symbol: '', + decimals: 0, + rawAmount: tokenObject.value?.toString() ?? '0', + uiAmount: '0', + }; }) ?? []; return trc10Assets; @@ -493,7 +522,19 @@ export class AssetsService { > = {}; for (const assetType of assetTypes) { - nativeTokensMetadata[assetType] = TRX_METADATA; + nativeTokensMetadata[assetType] = { + fungible: TRX_METADATA.fungible, + name: TRX_METADATA.name, + symbol: TRX_METADATA.symbol, + iconUrl: TRX_METADATA.iconUrl, + units: [ + { + decimals: TRX_METADATA.decimals, + symbol: TRX_METADATA.symbol, + name: TRX_METADATA.name, + }, + ], + }; } return nativeTokensMetadata; @@ -512,13 +553,37 @@ export class AssetsService { const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); if (isForBandwdidth) { - stakedTokensMetadata[assetType] = TRX_STAKED_FOR_BANDWIDTH_METADATA; + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_BANDWIDTH_METADATA.fungible, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_BANDWIDTH_METADATA.decimals, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + }, + ], + }; } const isForEnergy = assetType.endsWith('staked-for-energy'); if (isForEnergy) { - stakedTokensMetadata[assetType] = TRX_STAKED_FOR_ENERGY_METADATA; + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_ENERGY_METADATA.fungible, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_ENERGY_METADATA.decimals, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + }, + ], + }; } } @@ -534,7 +599,19 @@ export class AssetsService { > = {}; for (const assetType of assetTypes) { - bandwidthTokensMetadata[assetType] = BANDWIDTH_METADATA; + bandwidthTokensMetadata[assetType] = { + fungible: BANDWIDTH_METADATA.fungible, + name: BANDWIDTH_METADATA.name, + symbol: BANDWIDTH_METADATA.symbol, + iconUrl: BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: BANDWIDTH_METADATA.decimals, + symbol: BANDWIDTH_METADATA.symbol, + name: BANDWIDTH_METADATA.name, + }, + ], + }; } return bandwidthTokensMetadata; @@ -549,7 +626,19 @@ export class AssetsService { > = {}; for (const assetType of assetTypes) { - energyTokensMetadata[assetType] = ENERGY_METADATA; + energyTokensMetadata[assetType] = { + fungible: ENERGY_METADATA.fungible, + name: ENERGY_METADATA.name, + symbol: ENERGY_METADATA.symbol, + iconUrl: ENERGY_METADATA.iconUrl, + units: [ + { + decimals: ENERGY_METADATA.decimals, + symbol: ENERGY_METADATA.symbol, + name: ENERGY_METADATA.name, + }, + ], + }; } return energyTokensMetadata; From 3d04b665255febf0ffca1d0522d4074591cc62f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:35:30 +0100 Subject: [PATCH 044/238] 1.4.0 (#50) This is the release candidate for version 1.4.0. --------- Co-authored-by: github-actions Co-authored-by: Antonio Regadas --- merged-packages/tron-wallet-snap/CHANGELOG.md | 10 +++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 2c4e4436..88bda0a6 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] + +### Fixed + +- Edit assets names and balance decimals ([#49](https://github.com/MetaMask/snap-tron-wallet/pull/49)) +- Send maximum Bandwidth and Energy as assets ([#42](https://github.com/MetaMask/snap-tron-wallet/pull/42)) + ## [1.3.0] ### Added @@ -68,7 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.0...v1.1.1 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 718f0c44..1fdcae0b 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.3.0", + "version": "1.4.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 118ef295..1e346be3 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.3.0", + "version": "1.4.0", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "6gSWouRP2LldHjb6jdToHigEHUt0q/VEQZJYPkUmJKw=", + "shasum": "o0thQK19x7B11ucLryQbGhcJNXSBoctRdD+H7zJE33o=", "location": { "npm": { "filePath": "dist/bundle.js", From 917c6d5cd34f6a29cac255c3ff382d2634c38550 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 16 Oct 2025 13:50:39 +0100 Subject: [PATCH 045/238] feat: implement staking and unstaking handlers (#46) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../clients/token-api/TokenApiClient.test.ts | 2 +- .../tron-wallet-snap/src/context.ts | 9 + .../handlers/clientRequest/clientRequest.ts | 250 +++++++++- .../src/handlers/clientRequest/types.ts | 12 +- .../src/handlers/clientRequest/validation.ts | 64 ++- .../src/services/accounts/AccountsService.ts | 20 + .../src/services/assets/AssetsService.ts | 24 + .../src/services/assets/types.ts | 18 +- .../src/services/send/SendService.ts | 18 +- .../services/staking/StakingService.test.ts | 426 ++++++++++++++++++ .../src/services/staking/StakingService.ts | 159 +++++++ .../src/validation/structs.ts | 4 +- 13 files changed, 953 insertions(+), 55 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts create mode 100644 merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 1e346be3..2424baa6 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "o0thQK19x7B11ucLryQbGhcJNXSBoctRdD+H7zJE33o=", + "shasum": "UIdglN3XU5zjZSVvyuXgVyfURh91x6rq8yO62Nr1704=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts index db17b8b1..ee36974e 100644 --- a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts @@ -177,7 +177,7 @@ describe('TokenApiClient', () => { }); await expect(client.getTokensMetadata(tokenAddresses)).rejects.toThrow( - 'At path: 0.assetId -- Expected a string matching `/^tron:(728126428|3448148188|2494104990|localnet)\\/(trc10|trc20):[a-zA-Z0-9]+$/` but received "bad-asset-id"', + 'At path: 0.assetId -- Expected a value of type `CaipAssetType`, but received: `"bad-asset-id"`', ); }); diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 2299d986..11fc762d 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -19,6 +19,7 @@ import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; import { FeeCalculatorService } from './services/send/FeeCalculatorService'; import { SendService } from './services/send/SendService'; +import { StakingService } from './services/staking/StakingService'; import type { UnencryptedStateValue } from './services/state/State'; import { State } from './services/state/State'; import { TransactionsRepository } from './services/transactions/TransactionsRepository'; @@ -110,6 +111,13 @@ const sendService = new SendService({ snapClient, }); +const stakingService = new StakingService({ + logger, + accountsService, + tronWebFactory, + snapClient, +}); + /** * Handlers */ @@ -125,6 +133,7 @@ const clientRequestHandler = new ClientRequestHandler({ tronWebFactory, feeCalculatorService, snapClient, + stakingService, }); const cronHandler = new CronHandler({ logger, diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 94a67408..50956071 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -9,8 +9,13 @@ import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import { Networks } from '../../constants'; import type { AccountsService } from '../../services/accounts/AccountsService'; import type { AssetsService } from '../../services/assets/AssetsService'; +import type { + NativeCaipAssetType, + StakedCaipAssetType, +} from '../../services/assets/types'; import type { FeeCalculatorService } from '../../services/send/FeeCalculatorService'; import type { SendService } from '../../services/send/SendService'; +import type { StakingService } from '../../services/staking/StakingService'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import { BackgroundEventMethod } from '../cronjob'; @@ -21,6 +26,9 @@ import { OnAddressInputRequestStruct, OnAmountInputRequestStruct, OnConfirmSendRequestStruct, + OnConfirmStakeRequestStruct, + OnStakeAmountInputRequestStruct, + OnUnstakeAmountInputRequestStruct, SignAndSendTransactionRequestStruct, } from './validation'; @@ -39,6 +47,8 @@ export class ClientRequestHandler { readonly #snapClient: SnapClient; + readonly #stakingService: StakingService; + constructor({ logger, accountsService, @@ -47,6 +57,7 @@ export class ClientRequestHandler { feeCalculatorService, tronWebFactory, snapClient, + stakingService, }: { logger: ILogger; accountsService: AccountsService; @@ -55,6 +66,7 @@ export class ClientRequestHandler { feeCalculatorService: FeeCalculatorService; tronWebFactory: TronWebFactory; snapClient: SnapClient; + stakingService: StakingService; }) { this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); this.#accountsService = accountsService; @@ -63,6 +75,7 @@ export class ClientRequestHandler { this.#feeCalculatorService = feeCalculatorService; this.#tronWebFactory = tronWebFactory; this.#snapClient = snapClient; + this.#stakingService = stakingService; } /** @@ -83,14 +96,28 @@ export class ClientRequestHandler { switch (method as ClientRequestMethod) { case ClientRequestMethod.SignAndSendTransaction: return this.#handleSignAndSendTransaction(request); - case ClientRequestMethod.ConfirmSend: - return this.#handleConfirmSend(request); - case ClientRequestMethod.ComputeFee: - return this.#handleComputeFee(request); + /** + * Unified non-EVM Send + */ case ClientRequestMethod.OnAddressInput: return this.#handleOnAddressInput(request); case ClientRequestMethod.OnAmountInput: return this.#handleOnAmountInput(request); + case ClientRequestMethod.ConfirmSend: + return this.#handleConfirmSend(request); + case ClientRequestMethod.ComputeFee: + return this.#handleComputeFee(request); + /** + * Staking + */ + case ClientRequestMethod.OnStakeAmountInput: + return this.#handleOnStakeAmountInput(request); + case ClientRequestMethod.ConfirmStake: + return this.#handleConfirmStake(request); + case ClientRequestMethod.OnUnstakeAmountInput: + return this.#handleOnUnstakeAmountInput(request); + case ClientRequestMethod.ConfirmUnstake: + return this.#handleConfirmUnstake(request); default: throw new MethodNotFoundError() as Error; } @@ -113,11 +140,7 @@ export class ClientRequestHandler { const { transaction, accountId, scope } = request.params; - const account = await this.#accountsService.findById(accountId); - - if (!account) { - throw new Error(`Account with ID ${accountId} not found`); - } + const account = await this.#accountsService.findByIdOrThrow(accountId); const keypair = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, @@ -169,15 +192,6 @@ export class ClientRequestHandler { assetId, }); - /** - * Sync account after a transaction - */ - await this.#snapClient.scheduleBackgroundEvent({ - method: BackgroundEventMethod.SynchronizeAccount, - params: { accountId: fromAccountId }, - duration: 'PT5S', - }); - return { transactionId: transaction.txId, status: TransactionStatus.Submitted, @@ -301,11 +315,7 @@ export class ClientRequestHandler { params: { scope, transaction, accountId }, } = request; - const account = await this.#accountsService.findById(accountId); - - if (!account) { - throw new Error(`Account with ID ${accountId} not found`); - } + await this.#accountsService.findByIdOrThrow(accountId); const assets = await this.#assetsService.getAssetsByAccountId(accountId); @@ -340,4 +350,198 @@ export class ClientRequestHandler { return result; } + + /** + * Handles the input of a stake amount. Checks if the user has enough of the asset + * to do the stake. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleOnStakeAmountInput(request: JsonRpcRequest): Promise { + try { + assert(request, OnStakeAmountInputRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { accountId, assetId, value } = request.params; + + await this.#accountsService.findByIdOrThrow(accountId); + const asset = await this.#assetsService.getAssetOrThrow(accountId, assetId); + + const accountBalance = BigNumber(asset.uiAmount); + const requestBalance = BigNumber(value); + + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }; + } + + return { + valid: true, + errors: [], + }; + } + + /** + * Handles the confirmation of a stake. Checks if the user has enough of the asset + * to do the stake and then stakes the asset. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleConfirmStake(request: JsonRpcRequest): Promise { + try { + assert(request, OnConfirmStakeRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { + fromAccountId, + assetId, + value, + options: { purpose }, + } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + const asset = await this.#assetsService.getAssetOrThrow( + fromAccountId, + assetId, + ); + + /** + * Check if account has the asset... + */ + if (!asset) { + return { + valid: false, + errors: [SendErrorCodes.Invalid], + }; + } + + const accountBalance = BigNumber(asset.uiAmount); + const requestBalance = BigNumber(value); + + /** + * Check if account has enough of the asset... + */ + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }; + } + + /** + * All good. Let's stake. + */ + await this.#stakingService.stake({ + account, + assetId: assetId as NativeCaipAssetType, + amount: requestBalance, + purpose, + }); + + return { + valid: true, + errors: [], + }; + } + + /** + * Check if we have enough of the asset to unstake. Keep in mind + * that you can unstake TRX that is allocated for Bandwidth or for Energy. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleOnUnstakeAmountInput(request: JsonRpcRequest): Promise { + try { + assert(request, OnUnstakeAmountInputRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { accountId, assetId, value } = request.params; + + await this.#accountsService.findByIdOrThrow(accountId); + const asset = await this.#assetsService.getAssetOrThrow(accountId, assetId); + + const accountBalance = BigNumber(asset.uiAmount); + const requestBalance = BigNumber(value); + + /** + * Check if account has enough of the asset... + */ + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }; + } + + return { + valid: true, + errors: [], + }; + } + + /** + * Handles the confirmation of an unstake. Checks if the user has enough of the asset + * to do the unstake. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleConfirmUnstake(request: JsonRpcRequest): Promise { + try { + assert(request, OnUnstakeAmountInputRequestStruct); + } catch (error) { + const errorToThrow = new InvalidParamsError() as Error; + errorToThrow.cause = error; + throw errorToThrow; + } + + const { accountId, assetId, value } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(accountId); + const asset = await this.#assetsService.getAssetOrThrow(accountId, assetId); + + const accountBalance = BigNumber(asset.uiAmount); + const requestBalance = BigNumber(value); + + /** + * Check if account has enough of the asset... + */ + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }; + } + + /** + * All good. Let's unstake. + */ + await this.#stakingService.unstake({ + account, + assetId: assetId as StakedCaipAssetType, + amount: requestBalance, + }); + + return { + valid: true, + errors: [], + }; + } } diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts index 3ba2f23b..f42499e5 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts @@ -1,9 +1,19 @@ export enum ClientRequestMethod { + SignAndSendTransaction = 'signAndSendTransaction', + /** + * Unified non-EVM Send + */ ConfirmSend = 'confirmSend', ComputeFee = 'computeFee', OnAddressInput = 'onAddressInput', OnAmountInput = 'onAmountInput', - SignAndSendTransaction = 'signAndSendTransaction', + /** + * Staking + Unstaking + */ + OnStakeAmountInput = 'onStakeAmountInput', + ConfirmStake = 'confirmStake', + OnUnstakeAmountInput = 'onUnstakeAmountInput', + ConfirmUnstake = 'confirmUnstake', } export enum SendErrorCodes { diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index 61658698..47babf58 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -9,12 +9,15 @@ import { } from '@metamask/utils'; import { ClientRequestMethod, SendErrorCodes } from './types'; +import { + NativeCaipAssetTypeStruct, + StakedCaipAssetTypeStruct, +} from '../../services/assets/types'; import { Base64Struct, PositiveNumberStringStruct, ScopeStringStruct, TronAddressStruct, - TronCaipAssetTypeStruct, UuidStruct, } from '../../validation/structs'; @@ -69,9 +72,9 @@ export const OnAddressInputRequestStruct = object({ * onAmountInput request/response validation. */ export const OnAmountInputRequestParamsStruct = object({ - value: PositiveNumberStringStruct, accountId: UuidStruct, - assetId: TronCaipAssetTypeStruct, + assetId: CaipAssetTypeStruct, + value: PositiveNumberStringStruct, }); export const OnAmountInputRequestStruct = object({ @@ -116,3 +119,58 @@ export const ComputeFeeResponseStruct = array( ); export type ComputeFeeResponse = Infer; + +export const OnStakeAmountInputRequestParamsStruct = object({ + accountId: UuidStruct, + assetId: NativeCaipAssetTypeStruct, + value: PositiveNumberStringStruct, +}); + +export const OnStakeAmountInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnStakeAmountInput), + params: OnStakeAmountInputRequestParamsStruct, +}); + +export const OnConfirmStakeRequestParamsStruct = object({ + fromAccountId: UuidStruct, + assetId: NativeCaipAssetTypeStruct, + value: PositiveNumberStringStruct, + options: object({ + purpose: enums(['ENERGY', 'BANDWIDTH']), + }), +}); + +export const OnConfirmStakeRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ConfirmStake), + params: OnConfirmStakeRequestParamsStruct, +}); + +export const OnUnstakeAmountInputRequestParamsStruct = object({ + accountId: UuidStruct, + assetId: StakedCaipAssetTypeStruct, + value: PositiveNumberStringStruct, +}); + +export const OnUnstakeAmountInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnUnstakeAmountInput), + params: OnUnstakeAmountInputRequestParamsStruct, +}); + +export const OnConfirmUnstakeRequestParamsStruct = object({ + accountId: UuidStruct, + assetId: StakedCaipAssetTypeStruct, + value: PositiveNumberStringStruct, +}); + +export const OnConfirmUnstakeRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ConfirmUnstake), + params: OnConfirmUnstakeRequestParamsStruct, +}); diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 36a7fe0b..1711421f 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -92,6 +92,7 @@ export class AccountsService { }): Promise<{ privateKeyBytes: Uint8Array; publicKeyBytes: Uint8Array; + privateKeyHex: string; address: string; }> { this.#logger.log({ derivationPath }, 'Generating TRON wallet'); @@ -122,6 +123,7 @@ export class AccountsService { return { privateKeyBytes, publicKeyBytes, + privateKeyHex: node.privateKey, address, }; } @@ -277,6 +279,24 @@ export class AccountsService { return this.#accountsRepository.findById(id); } + /** + * Retrieves an account by ID and throws an error if not found. + * This is a convenience method that combines findById with validation. + * + * @param id - The account ID to retrieve. + * @returns The account if found. + * @throws {Error} If the account is not found. + */ + async findByIdOrThrow(id: string): Promise { + const account = await this.#accountsRepository.findById(id); + + if (!account) { + throw new Error(`Account with ID ${id} not found`); + } + + return account; + } + async findByAddress(address: string): Promise { return this.#accountsRepository.findByAddress(address); } diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index f70fe2fc..2f17be9f 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -817,6 +817,30 @@ export class AssetsService { return this.#assetsRepository.getByAccountId(keyringAccountId); } + /** + * Retrieves a specific asset for an account and throws an error if not found. + * This is a convenience method that combines getByKeyringAccountId with asset filtering and validation. + * + * @param accountId - The account ID to retrieve the asset for. + * @param assetType - The asset type to retrieve. + * @returns The asset if found. + * @throws {Error} If the asset is not found. + */ + async getAssetOrThrow( + accountId: string, + assetType: string, + ): Promise { + const assets = await this.getByKeyringAccountId(accountId); + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + const asset = assets.find((assetItem) => assetItem.assetType === assetType); + + if (!asset) { + throw new Error(`Asset ${assetType} not found for account ${accountId}`); + } + + return asset; + } + /** * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. * diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts index 2fe5cd28..e1266318 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -1,7 +1,7 @@ -import type { TrxScope } from '@metamask/keyring-api'; -import { pattern, string } from '@metamask/superstruct'; +import { CaipAssetTypeStruct, type TrxScope } from '@metamask/keyring-api'; +import { pattern } from '@metamask/superstruct'; -import type { Network } from '../../constants'; +import { type Network } from '../../constants'; export type NativeCaipAssetType = `${Network}/slip44:195`; export type StakedCaipAssetType = @@ -15,7 +15,7 @@ export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; * Validates a TRON native CAIP-19 ID (e.g., "tron:728126428/slip44:195") */ export const NativeCaipAssetTypeStruct = pattern( - string(), + CaipAssetTypeStruct, /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:195$/u, ); @@ -23,7 +23,7 @@ export const NativeCaipAssetTypeStruct = pattern( * Validates a TRON native CAIP-19 ID (e.g., "tron:728126428/slip44:195") */ export const StakedCaipAssetTypeStruct = pattern( - string(), + CaipAssetTypeStruct, /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:195-staked-for-(energy|bandwidth)$/u, ); @@ -31,7 +31,7 @@ export const StakedCaipAssetTypeStruct = pattern( * Validates a TRON native CAIP-19 ID for resources (e.g., "tron:728126428/energy" or "tron:728126428/bandwidth") */ export const ResourceCaipAssetTypeStruct = pattern( - string(), + CaipAssetTypeStruct, /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:(energy|bandwidth)$/u, ); @@ -39,7 +39,7 @@ export const ResourceCaipAssetTypeStruct = pattern( * Validates a TRON maximum resource CAIP-19 ID (e.g., "tron:728126428/slip44:maximum-energy") */ export const MaximumResourceCaipAssetTypeStruct = pattern( - string(), + CaipAssetTypeStruct, /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:maximum-(energy|bandwidth)$/u, ); @@ -47,7 +47,7 @@ export const MaximumResourceCaipAssetTypeStruct = pattern( * Validates a TRON token CAIP-19 ID (e.g., "tron:728126428/trc10:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t") */ export const TokenCaipAssetTypeStruct = pattern( - string(), + CaipAssetTypeStruct, /^tron:(728126428|3448148188|2494104990|localnet)\/(trc10|trc20):[a-zA-Z0-9]+$/u, ); @@ -55,6 +55,6 @@ export const TokenCaipAssetTypeStruct = pattern( * Validates a TRON NFT CAIP-19 ID (e.g., "tron:728126428/trc721:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") */ export const NftCaipAssetTypeStruct = pattern( - string(), + CaipAssetTypeStruct, /^tron:(728126428|3448148188|2494104990|localnet)\/trc721:[a-zA-Z0-9]+$/u, ); diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts index 50f158f7..92a41f1a 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -102,11 +102,7 @@ export class SendService { toAddress: string; amount: number; }): Promise { - const account = await this.#accountsService.findById(fromAccountId); - - if (!account) { - throw new Error(`Account with ID ${fromAccountId} not found`); - } + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); const keypair = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, @@ -182,11 +178,7 @@ export class SendService { amount: number; tokenId: string; }): Promise { - const account = await this.#accountsService.findById(fromAccountId); - - if (!account) { - throw new Error(`Account with ID ${fromAccountId} not found`); - } + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); const keypair = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, @@ -262,11 +254,7 @@ export class SendService { amount: number; contractAddress: string; }): Promise { - const account = await this.#accountsService.findById(fromAccountId); - - if (!account) { - throw new Error(`Account with ID ${fromAccountId} not found`); - } + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); const keypair = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts new file mode 100644 index 00000000..0fae4990 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts @@ -0,0 +1,426 @@ +import { BigNumber } from 'bignumber.js'; + +import { StakingService } from './StakingService'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import { KnownCaip19Id, Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { BackgroundEventMethod } from '../../handlers/cronjob'; +import { mockLogger } from '../../utils/mockLogger'; +import type { AccountsService } from '../accounts/AccountsService'; + +describe('StakingService', () => { + let stakingService: StakingService; + let mockAccountsService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockTronWeb: any; + + const mockAccount: TronKeyringAccount = { + id: 'test-account-id', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + const mockKeypair = { + privateKeyHex: + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + // eslint-disable-next-line no-restricted-globals + privateKeyBytes: Buffer.from( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'hex', + ), + // eslint-disable-next-line no-restricted-globals + publicKeyBytes: Buffer.from( + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + 'hex', + ), + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + }; + + const mockTransaction = { + txID: 'mock-transaction-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + contract: [ + { + type: 'FreezeBalanceV2Contract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + // eslint-disable-next-line @typescript-eslint/naming-convention + frozen_balance: 1000000, + resource: 'BANDWIDTH', + }, + }, + }, + ], + }, + }; + + const mockSignedTransaction = { + ...mockTransaction, + signature: ['mock-signature'], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockTronWeb = { + transactionBuilder: { + freezeBalanceV2: jest.fn().mockResolvedValue(mockTransaction), + unfreezeBalanceV2: jest.fn().mockResolvedValue(mockTransaction), + }, + trx: { + sign: jest.fn().mockResolvedValue(mockSignedTransaction), + sendRawTransaction: jest + .fn() + .mockResolvedValue({ result: true, txid: 'mock-tx-id' }), + }, + }; + + mockAccountsService = { + deriveTronKeypair: jest.fn().mockResolvedValue(mockKeypair), + findByIdOrThrow: jest.fn().mockResolvedValue(mockAccount), + } as unknown as jest.Mocked; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + } as unknown as jest.Mocked; + + mockSnapClient = { + scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + stakingService = new StakingService({ + logger: mockLogger, + accountsService: mockAccountsService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + }); + }); + + describe('stake', () => { + it('successfully stakes TRX for BANDWIDTH', async () => { + const amount = BigNumber(1000000); + const assetId = KnownCaip19Id.TrxMainnet; + const purpose = 'BANDWIDTH'; + + await stakingService.stake({ + account: mockAccount, + assetId, + amount, + purpose, + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + derivationPath: mockAccount.derivationPath, + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + mockKeypair.privateKeyHex, + ); + + expect( + mockTronWeb.transactionBuilder.freezeBalanceV2, + ).toHaveBeenCalledWith(amount.toNumber(), purpose, mockAccount.address); + + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(mockTransaction); + expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith( + mockSignedTransaction, + ); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: mockAccount.id }, + duration: 'PT5S', + }); + }); + + it('successfully stakes TRX for ENERGY', async () => { + const amount = BigNumber(2000000); + const assetId = KnownCaip19Id.TrxNile; + const purpose = 'ENERGY'; + + await stakingService.stake({ + account: mockAccount, + assetId, + amount, + purpose, + }); + + expect( + mockTronWeb.transactionBuilder.freezeBalanceV2, + ).toHaveBeenCalledWith(amount.toNumber(), purpose, mockAccount.address); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Nile, + mockKeypair.privateKeyHex, + ); + }); + + it('correctly parses chainId from different network asset IDs', async () => { + const testCases = [ + { assetId: KnownCaip19Id.TrxMainnet, expectedNetwork: Network.Mainnet }, + { assetId: KnownCaip19Id.TrxNile, expectedNetwork: Network.Nile }, + { assetId: KnownCaip19Id.TrxShasta, expectedNetwork: Network.Shasta }, + { + assetId: KnownCaip19Id.TrxLocalnet, + expectedNetwork: Network.Localnet, + }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.stake({ + account: mockAccount, + assetId: testCase.assetId as any, + amount: BigNumber(1000000), + purpose: 'BANDWIDTH', + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + testCase.expectedNetwork, + mockKeypair.privateKeyHex, + ); + } + }); + + it('handles different amount values correctly', async () => { + const testCases = [ + { amount: BigNumber(1), expectedNumber: 1 }, + { amount: BigNumber(1000000), expectedNumber: 1000000 }, + { amount: BigNumber('1000000000000'), expectedNumber: 1000000000000 }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.stake({ + account: mockAccount, + assetId: KnownCaip19Id.TrxMainnet, + amount: testCase.amount, + purpose: 'BANDWIDTH', + }); + + expect( + mockTronWeb.transactionBuilder.freezeBalanceV2, + ).toHaveBeenCalledWith( + testCase.expectedNumber, + 'BANDWIDTH', + mockAccount.address, + ); + } + }); + + it('correctly derives keypair for staking', async () => { + const customAccount = { + ...mockAccount, + entropySource: 'custom-entropy', + derivationPath: "m/44'/195'/1'/0/0" as const, + }; + + await stakingService.stake({ + account: customAccount, + assetId: KnownCaip19Id.TrxMainnet as any, + amount: BigNumber(1000000), + purpose: 'BANDWIDTH', + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: 'custom-entropy', + derivationPath: "m/44'/195'/1'/0/0", + }); + }); + }); + + describe('unstake', () => { + it('successfully unstakes TRX staked for BANDWIDTH on Mainnet', async () => { + const amount = BigNumber(1000000); + const assetId = KnownCaip19Id.TrxStakedForBandwidthMainnet; + + await stakingService.unstake({ + account: mockAccount, + assetId, + amount, + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + derivationPath: mockAccount.derivationPath, + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + mockKeypair.privateKeyHex, + ); + + expect( + mockTronWeb.transactionBuilder.unfreezeBalanceV2, + ).toHaveBeenCalledWith( + amount.toNumber(), + 'BANDWIDTH', + mockAccount.address, + ); + + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(mockTransaction); + expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith( + mockSignedTransaction, + ); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: mockAccount.id }, + duration: 'PT5S', + }); + }); + + it('successfully unstakes TRX staked for BANDWIDTH on all networks', async () => { + const testCases = [ + { + assetId: KnownCaip19Id.TrxStakedForBandwidthMainnet, + expectedNetwork: Network.Mainnet, + }, + { + assetId: KnownCaip19Id.TrxStakedForBandwidthNile, + expectedNetwork: Network.Nile, + }, + { + assetId: KnownCaip19Id.TrxStakedForBandwidthShasta, + expectedNetwork: Network.Shasta, + }, + { + assetId: KnownCaip19Id.TrxStakedForBandwidthLocalnet, + expectedNetwork: Network.Localnet, + }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.unstake({ + account: mockAccount, + assetId: testCase.assetId as any, + amount: BigNumber(1000000), + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + testCase.expectedNetwork, + mockKeypair.privateKeyHex, + ); + + expect( + mockTronWeb.transactionBuilder.unfreezeBalanceV2, + ).toHaveBeenCalledWith(1000000, 'BANDWIDTH', mockAccount.address); + } + }); + + it('successfully unstakes TRX staked for ENERGY on all networks', async () => { + const testCases = [ + { + assetId: KnownCaip19Id.TrxStakedForEnergyMainnet, + expectedNetwork: Network.Mainnet, + }, + { + assetId: KnownCaip19Id.TrxStakedForEnergyNile, + expectedNetwork: Network.Nile, + }, + { + assetId: KnownCaip19Id.TrxStakedForEnergyShasta, + expectedNetwork: Network.Shasta, + }, + { + assetId: KnownCaip19Id.TrxStakedForEnergyLocalnet, + expectedNetwork: Network.Localnet, + }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.unstake({ + account: mockAccount, + assetId: testCase.assetId as any, + amount: BigNumber(2000000), + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + testCase.expectedNetwork, + mockKeypair.privateKeyHex, + ); + + expect( + mockTronWeb.transactionBuilder.unfreezeBalanceV2, + ).toHaveBeenCalledWith(2000000, 'ENERGY', mockAccount.address); + } + }); + + it('throws error for invalid asset ID', async () => { + const invalidAssetId = 'tron:728126428/slip44:invalid' as any; + const amount = BigNumber(1000000); + + await expect( + stakingService.unstake({ + account: mockAccount, + assetId: invalidAssetId, + amount, + }), + ).rejects.toThrow('Invalid asset ID'); + }); + + it('correctly derives keypair for unstaking', async () => { + const customAccount = { + ...mockAccount, + entropySource: 'custom-entropy', + derivationPath: "m/44'/195'/2'/0/0" as const, + }; + + await stakingService.unstake({ + account: customAccount, + assetId: KnownCaip19Id.TrxStakedForBandwidthMainnet as any, + amount: BigNumber(1000000), + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: 'custom-entropy', + derivationPath: "m/44'/195'/2'/0/0", + }); + }); + + it('handles different amount values correctly', async () => { + const testCases = [ + { amount: BigNumber(1), expectedNumber: 1 }, + { amount: BigNumber(1000000), expectedNumber: 1000000 }, + { amount: BigNumber('1000000000000'), expectedNumber: 1000000000000 }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.unstake({ + account: mockAccount, + assetId: KnownCaip19Id.TrxStakedForBandwidthMainnet, + amount: testCase.amount, + }); + + expect( + mockTronWeb.transactionBuilder.unfreezeBalanceV2, + ).toHaveBeenCalledWith( + testCase.expectedNumber, + 'BANDWIDTH', + mockAccount.address, + ); + } + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts new file mode 100644 index 00000000..b874a5a2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts @@ -0,0 +1,159 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import type { Resource } from 'tronweb/lib/esm/types'; + +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import type { Network } from '../../constants'; +import { KnownCaip19Id } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { BackgroundEventMethod } from '../../handlers/cronjob'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { NativeCaipAssetType, StakedCaipAssetType } from '../assets/types'; + +export class StakingService { + readonly #logger: ILogger; + + readonly #accountsService: AccountsService; + + readonly #tronWebFactory: TronWebFactory; + + readonly #snapClient: SnapClient; + + constructor({ + logger, + accountsService, + tronWebFactory, + snapClient, + }: { + logger: ILogger; + accountsService: AccountsService; + tronWebFactory: TronWebFactory; + snapClient: SnapClient; + }) { + this.#logger = createPrefixedLogger(logger, '[💸 StakingService]'); + this.#accountsService = accountsService; + this.#tronWebFactory = tronWebFactory; + this.#snapClient = snapClient; + } + + async stake({ + account, + assetId, + amount, + purpose, + }: { + account: TronKeyringAccount; + assetId: NativeCaipAssetType; + amount: BigNumber; + purpose: 'BANDWIDTH' | 'ENERGY'; + }): Promise { + this.#logger.info( + `Staking ${amount.toString()} ${assetId} from ${account.address} for ${purpose}...`, + ); + + const { chainId } = parseCaipAssetType(assetId); + + const keypair = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + const tronWeb = this.#tronWebFactory.createClient( + chainId as Network, + keypair.privateKeyHex, + ); + + const transaction = await tronWeb.transactionBuilder.freezeBalanceV2( + amount.toNumber(), + purpose, + account.address, + ); + const signedTx = await tronWeb.trx.sign(transaction); + await tronWeb.trx.sendRawTransaction(signedTx); + + /** + * Sync account after the transaction happens + */ + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: account.id }, + duration: 'PT5S', + }); + } + + async unstake({ + account, + assetId, + amount, + }: { + account: TronKeyringAccount; + assetId: StakedCaipAssetType; + amount: BigNumber; + }): Promise { + this.#logger.info( + `Unstaking ${amount.toString()} ${assetId} from ${account.address}...`, + ); + + const { chainId } = parseCaipAssetType(assetId); + + const keypair = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + const tronWeb = this.#tronWebFactory.createClient( + chainId as Network, + keypair.privateKeyHex, + ); + + /** + * Check which resource we are unstaking. + */ + let purpose: Resource | undefined; + + if ( + [ + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForBandwidthNile, + KnownCaip19Id.TrxStakedForBandwidthShasta, + KnownCaip19Id.TrxStakedForBandwidthLocalnet, + ].includes(assetId as KnownCaip19Id) + ) { + purpose = 'BANDWIDTH'; + } + + if ( + [ + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxStakedForEnergyNile, + KnownCaip19Id.TrxStakedForEnergyShasta, + KnownCaip19Id.TrxStakedForEnergyLocalnet, + ].includes(assetId as KnownCaip19Id) + ) { + purpose = 'ENERGY'; + } + + if (!purpose) { + throw new Error('Invalid asset ID'); + } + + const transaction = await tronWeb.transactionBuilder.unfreezeBalanceV2( + amount.toNumber(), + purpose, + account.address, + ); + const signedTx = await tronWeb.trx.sign(transaction); + await tronWeb.trx.sendRawTransaction(signedTx); + + /** + * Sync account after the transaction happens + */ + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: account.id }, + duration: 'PT5S', + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.ts b/merged-packages/tron-wallet-snap/src/validation/structs.ts index 44cd0a93..4d011077 100644 --- a/merged-packages/tron-wallet-snap/src/validation/structs.ts +++ b/merged-packages/tron-wallet-snap/src/validation/structs.ts @@ -339,11 +339,11 @@ export const TronAddressStruct: Struct = define( }, ); -export const TronCaipAssetTypeStruct: Struct = union([ +export const TronCaipAssetTypeStruct = union([ NativeCaipAssetTypeStruct, StakedCaipAssetTypeStruct, TokenCaipAssetTypeStruct, NftCaipAssetTypeStruct, ResourceCaipAssetTypeStruct, MaximumResourceCaipAssetTypeStruct, -]); +]) as Struct; From 5e8dc7cad2b4ed85728a434047a80a2e5da321f7 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 16 Oct 2025 17:23:31 +0100 Subject: [PATCH 046/238] chore: implement safe error handling so that the Snap never crashes (#51) --- .../handlers/clientRequest/clientRequest.ts | 278 ++++++++---------- merged-packages/tron-wallet-snap/src/index.ts | 31 +- .../src/services/assets/AssetsRepository.ts | 9 + .../src/services/assets/AssetsService.ts | 34 +-- .../src/utils/assertOrThrow.ts | 24 ++ .../tron-wallet-snap/src/utils/errors.test.ts | 5 +- 6 files changed, 190 insertions(+), 191 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/utils/assertOrThrow.ts diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 50956071..9220cb18 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -16,6 +16,7 @@ import type { import type { FeeCalculatorService } from '../../services/send/FeeCalculatorService'; import type { SendService } from '../../services/send/SendService'; import type { StakingService } from '../../services/staking/StakingService'; +import { assertOrThrow } from '../../utils/assertOrThrow'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import { BackgroundEventMethod } from '../cronjob'; @@ -94,6 +95,9 @@ export class ClientRequestHandler { const { method } = request; switch (method as ClientRequestMethod) { + /** + * Wallet Standard + */ case ClientRequestMethod.SignAndSendTransaction: return this.#handleSignAndSendTransaction(request); /** @@ -130,13 +134,11 @@ export class ClientRequestHandler { * @returns The transaction result with hash and status. */ async #handleSignAndSendTransaction(request: JsonRpcRequest): Promise { - try { - assert(request, SignAndSendTransactionRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } + assertOrThrow( + request, + SignAndSendTransactionRequestStruct, + new InvalidParamsError(), + ); const { transaction, accountId, scope } = request.params; @@ -168,59 +170,25 @@ export class ClientRequestHandler { }; } - /** - * Handles the confirmation and sending of a transaction. - * - * @param request - The JSON-RPC request containing transaction details. - * @returns The transaction result with hash and status. - */ - async #handleConfirmSend(request: JsonRpcRequest): Promise { - try { - assert(request, OnConfirmSendRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } - - const { fromAccountId, toAddress, amount, assetId } = request.params; - - const transaction = await this.#sendService.sendAsset({ - fromAccountId, - toAddress, - amount: BigNumber(amount).toNumber(), - assetId, - }); - - return { - transactionId: transaction.txId, - status: TransactionStatus.Submitted, - }; - } - /** * Handles the input of an address. * * @param request - The JSON-RPC request containing the method and parameters. * @returns The response to the JSON-RPC request. - * @throws {InvalidParamsError} If the params are invalid. */ async #handleOnAddressInput(request: JsonRpcRequest): Promise { try { assert(request, OnAddressInputRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; + return { + valid: true, + errors: [], + }; + } catch { + return { + valid: false, + errors: [SendErrorCodes.Invalid], + }; } - - /** - * If we reach this point, the address is valid (validated by TronAddressStruct) - */ - return { - valid: true, - errors: [], - }; } /** @@ -232,65 +200,79 @@ export class ClientRequestHandler { async #handleOnAmountInput(request: JsonRpcRequest): Promise { try { assert(request, OnAmountInputRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } - - /** - * Check if the user has enough of the asset - */ - const { accountId, assetId, value } = request.params; - const account = await this.#accountsService.findById(accountId); + /** + * Check if the user has enough of the asset + */ + const { accountId, assetId, value } = request.params; + const account = await this.#accountsService.findById(accountId); - /** - * The account does not exist... - */ - if (!account) { - return { - valid: false, - errors: [SendErrorCodes.Invalid], - }; - } + /** + * The account does not exist... + */ + if (!account) { + return { + valid: false, + errors: [SendErrorCodes.Invalid], + }; + } + + const asset = await this.#assetsService.getAssetByAccountId( + accountId, + assetId, + ); - const accountAssets = - await this.#assetsService.getByKeyringAccountId(accountId); + /** + * If the account doesn't have this asset, treat it as having zero balance + */ + const balance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); + const amount = BigNumber(value); - /** - * Typescript is not smart enough to infer that the validation above - * guarantees that assetId is a valid TronCaipAssetTypeStruct and the - * unsafe enum comparison is irrelevant. - */ - const asset = accountAssets.find( - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - (assetItem) => assetItem.assetType === assetId, - ); + if (amount.isGreaterThan(balance)) { + return { + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }; + } - /** - * The account does not have this asset... - */ - if (!asset) { + return { + valid: true, + errors: [], + }; + } catch (error) { + this.#logger.error('Error in #handleOnAmountInput:', error); return { valid: false, errors: [SendErrorCodes.Invalid], }; } + } - const balance = BigNumber(asset.uiAmount); - const amount = BigNumber(value); + /** + * Handles the confirmation and sending of a transaction. + * + * @param request - The JSON-RPC request containing transaction details. + * @returns The transaction result with hash and status. + */ + async #handleConfirmSend(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + OnConfirmSendRequestStruct, + new InvalidParamsError(), + ); - if (amount.isGreaterThan(balance)) { - return { - valid: false, - errors: [SendErrorCodes.InsufficientBalance], - }; - } + const { fromAccountId, toAddress, amount, assetId } = request.params; + + const transaction = await this.#sendService.sendAsset({ + fromAccountId, + toAddress, + amount: BigNumber(amount).toNumber(), + assetId, + }); return { - valid: true, - errors: [], + transactionId: transaction.txId, + status: TransactionStatus.Submitted, }; } @@ -303,13 +285,7 @@ export class ClientRequestHandler { * @throws {InvalidParamsError} If the params are invalid. */ async #handleComputeFee(request: JsonRpcRequest): Promise { - try { - assert(request, ComputeFeeRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } + assertOrThrow(request, ComputeFeeRequestStruct, new InvalidParamsError()); const { params: { scope, transaction, accountId }, @@ -317,16 +293,16 @@ export class ClientRequestHandler { await this.#accountsService.findByIdOrThrow(accountId); - const assets = await this.#assetsService.getAssetsByAccountId(accountId); - /** * Get available Energy and Bandwidth from account assets. */ - const energyAsset = assets.find( - (asset) => asset.assetType === Networks[scope].energy.id, + const energyAsset = await this.#assetsService.getAssetByAccountId( + accountId, + Networks[scope].energy.id, ); - const bandwidthAsset = assets.find( - (asset) => asset.assetType === Networks[scope].bandwidth.id, + const bandwidthAsset = await this.#assetsService.getAssetByAccountId( + accountId, + Networks[scope].bandwidth.id, ); const availableEnergy = energyAsset @@ -359,20 +335,24 @@ export class ClientRequestHandler { * @returns The response to the JSON-RPC request. */ async #handleOnStakeAmountInput(request: JsonRpcRequest): Promise { - try { - assert(request, OnStakeAmountInputRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } + assertOrThrow( + request, + OnStakeAmountInputRequestStruct, + new InvalidParamsError(), + ); const { accountId, assetId, value } = request.params; await this.#accountsService.findByIdOrThrow(accountId); - const asset = await this.#assetsService.getAssetOrThrow(accountId, assetId); + const asset = await this.#assetsService.getAssetByAccountId( + accountId, + assetId, + ); - const accountBalance = BigNumber(asset.uiAmount); + /** + * If the account doesn't have this asset, treat it as having zero balance + */ + const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); if (requestBalance.isGreaterThan(accountBalance)) { @@ -396,13 +376,11 @@ export class ClientRequestHandler { * @returns The response to the JSON-RPC request. */ async #handleConfirmStake(request: JsonRpcRequest): Promise { - try { - assert(request, OnConfirmStakeRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } + assertOrThrow( + request, + OnConfirmStakeRequestStruct, + new InvalidParamsError(), + ); const { fromAccountId, @@ -412,22 +390,12 @@ export class ClientRequestHandler { } = request.params; const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - const asset = await this.#assetsService.getAssetOrThrow( + const asset = await this.#assetsService.getAssetByAccountId( fromAccountId, assetId, ); - /** - * Check if account has the asset... - */ - if (!asset) { - return { - valid: false, - errors: [SendErrorCodes.Invalid], - }; - } - - const accountBalance = BigNumber(asset.uiAmount); + const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); /** @@ -464,20 +432,21 @@ export class ClientRequestHandler { * @returns The response to the JSON-RPC request. */ async #handleOnUnstakeAmountInput(request: JsonRpcRequest): Promise { - try { - assert(request, OnUnstakeAmountInputRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } + assertOrThrow( + request, + OnUnstakeAmountInputRequestStruct, + new InvalidParamsError(), + ); const { accountId, assetId, value } = request.params; await this.#accountsService.findByIdOrThrow(accountId); - const asset = await this.#assetsService.getAssetOrThrow(accountId, assetId); + const asset = await this.#assetsService.getAssetByAccountId( + accountId, + assetId, + ); - const accountBalance = BigNumber(asset.uiAmount); + const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); /** @@ -504,20 +473,21 @@ export class ClientRequestHandler { * @returns The response to the JSON-RPC request. */ async #handleConfirmUnstake(request: JsonRpcRequest): Promise { - try { - assert(request, OnUnstakeAmountInputRequestStruct); - } catch (error) { - const errorToThrow = new InvalidParamsError() as Error; - errorToThrow.cause = error; - throw errorToThrow; - } + assertOrThrow( + request, + OnUnstakeAmountInputRequestStruct, + new InvalidParamsError(), + ); const { accountId, assetId, value } = request.params; const account = await this.#accountsService.findByIdOrThrow(accountId); - const asset = await this.#assetsService.getAssetOrThrow(accountId, assetId); + const asset = await this.#assetsService.getAssetByAccountId( + accountId, + assetId, + ); - const accountBalance = BigNumber(asset.uiAmount); + const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); /** diff --git a/merged-packages/tron-wallet-snap/src/index.ts b/merged-packages/tron-wallet-snap/src/index.ts index 2be0b8e6..97d64c9a 100644 --- a/merged-packages/tron-wallet-snap/src/index.ts +++ b/merged-packages/tron-wallet-snap/src/index.ts @@ -20,6 +20,7 @@ import { rpcHandler, userInputHandler, } from './context'; +import { withCatchAndThrowSnapError } from './utils/errors'; /** * Register all handlers @@ -27,34 +28,44 @@ import { export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( args, -) => assetsHandler.onAssetHistoricalPrice(args); +) => + withCatchAndThrowSnapError(async () => + assetsHandler.onAssetHistoricalPrice(args), + ); export const onAssetsConversion: OnAssetsConversionHandler = async (args) => - assetsHandler.onAssetsConversion(args); + withCatchAndThrowSnapError(async () => + assetsHandler.onAssetsConversion(args), + ); export const onAssetsLookup: OnAssetsLookupHandler = async (args) => - assetsHandler.onAssetsLookup(args); + withCatchAndThrowSnapError(async () => assetsHandler.onAssetsLookup(args)); export const onAssetsMarketData: OnAssetsMarketDataHandler = async (args) => - assetsHandler.onAssetsMarketData(args); + withCatchAndThrowSnapError(async () => + assetsHandler.onAssetsMarketData(args), + ); export const onClientRequest: OnClientRequestHandler = async ({ request }) => - clientRequestHandler.handle(request); + withCatchAndThrowSnapError(async () => clientRequestHandler.handle(request)); export const onCronjob: OnCronjobHandler = async ({ request }) => - cronHandler.handle(request); + withCatchAndThrowSnapError(async () => cronHandler.handle(request)); export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, -}) => keyringHandler.handle(origin, request); +}) => + withCatchAndThrowSnapError(async () => + keyringHandler.handle(origin, request), + ); export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => - rpcHandler.handle(origin, request); + withCatchAndThrowSnapError(async () => rpcHandler.handle(origin, request)); export const onUserInput: OnUserInputHandler = async (params) => - userInputHandler.handle(params); + withCatchAndThrowSnapError(async () => userInputHandler.handle(params)); export const onActive: OnActiveHandler = async () => { - await lifecycleHandler.onActive(); + await withCatchAndThrowSnapError(async () => lifecycleHandler.onActive()); }; diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts index c53846e2..f3e7d1fe 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts @@ -19,6 +19,15 @@ export class AssetsRepository { return assets ?? []; } + async getByAccountIdAndAssetType( + keyringAccountId: string, + assetType: string, + ): Promise { + const assets = await this.getByAccountId(keyringAccountId); + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + return assets.find((asset) => asset.assetType === assetType) ?? null; + } + async getAll(): Promise { const assetsByAccount = (await this.#state.getKey('assets')) ?? diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 2f17be9f..96cddcff 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -103,6 +103,16 @@ export class AssetsService { return this.#assetsRepository.getByAccountId(accountId); } + async getAssetByAccountId( + accountId: string, + assetType: string, + ): Promise { + return this.#assetsRepository.getByAccountIdAndAssetType( + accountId, + assetType, + ); + } + async fetchAssetsAndBalancesForAccount( scope: Network, account: KeyringAccount, @@ -817,30 +827,6 @@ export class AssetsService { return this.#assetsRepository.getByAccountId(keyringAccountId); } - /** - * Retrieves a specific asset for an account and throws an error if not found. - * This is a convenience method that combines getByKeyringAccountId with asset filtering and validation. - * - * @param accountId - The account ID to retrieve the asset for. - * @param assetType - The asset type to retrieve. - * @returns The asset if found. - * @throws {Error} If the asset is not found. - */ - async getAssetOrThrow( - accountId: string, - assetType: string, - ): Promise { - const assets = await this.getByKeyringAccountId(accountId); - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - const asset = assets.find((assetItem) => assetItem.assetType === assetType); - - if (!asset) { - throw new Error(`Asset ${assetType} not found for account ${accountId}`); - } - - return asset; - } - /** * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. * diff --git a/merged-packages/tron-wallet-snap/src/utils/assertOrThrow.ts b/merged-packages/tron-wallet-snap/src/utils/assertOrThrow.ts new file mode 100644 index 00000000..80ebc6ae --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/assertOrThrow.ts @@ -0,0 +1,24 @@ +import type { Struct } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; + +/** + * Asserts that a value passes a struct and throws an error if it does not. + * + * @param value - The value to assert. + * @param struct - The struct to assert. + * @param errorToThrow - The error to throw. + * @param errorToThrow.cause - The cause of the error to throw. + * @throws The error if the value does not pass the struct. + */ +export function assertOrThrow( + value: unknown, + struct: Struct, + errorToThrow: { cause?: unknown }, +): asserts value is Type { + try { + assert(value, struct); + } catch (error) { + errorToThrow.cause = error; + throw errorToThrow as Error; + } +} diff --git a/merged-packages/tron-wallet-snap/src/utils/errors.test.ts b/merged-packages/tron-wallet-snap/src/utils/errors.test.ts index 561e7ea2..0945bf98 100644 --- a/merged-packages/tron-wallet-snap/src/utils/errors.test.ts +++ b/merged-packages/tron-wallet-snap/src/utils/errors.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { SnapError } from '@metamask/snaps-sdk'; import { withCatchAndThrowSnapError } from './errors'; @@ -45,7 +44,7 @@ describe('errors', () => { try { await withCatchAndThrowSnapError(mockFn); - } catch (error) { + } catch { // Expected to throw } @@ -154,7 +153,7 @@ describe('errors', () => { try { await withCatchAndThrowSnapError(mockFn); - } catch (error) { + } catch { // Expected to throw } From 8752c81bc44d8a2f3fa65b8226c4ff0a4b23dd10 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 17 Oct 2025 12:25:36 +0100 Subject: [PATCH 047/238] fix: match the new Keyring `createAccount` spec (#52) Notice the fields that are already being sent to the client Screenshot 2025-10-17 at 11 58 57 --- .../tron-wallet-snap/src/handlers/keyring.ts | 5 +- .../src/services/accounts/AccountsService.ts | 62 ++++--------------- .../src/services/accounts/types.ts | 3 +- 3 files changed, 15 insertions(+), 55 deletions(-) diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index 032ad636..fa492302 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -28,10 +28,10 @@ import type { } from '@metamask/utils'; import { sortBy } from 'lodash'; -import type { TronKeyringAccount } from '../entities'; -import { BackgroundEventMethod } from './cronjob'; import type { SnapClient } from '../clients/snap/SnapClient'; import type { Network } from '../constants'; +import type { TronKeyringAccount } from '../entities'; +import { BackgroundEventMethod } from './cronjob'; import type { AccountsService } from '../services/accounts/AccountsService'; import type { CreateAccountOptions } from '../services/accounts/types'; import type { AssetsService } from '../services/assets/AssetsService'; @@ -127,6 +127,7 @@ export class KeyringHandler implements Keyring { async createAccount(options?: CreateAccountOptions): Promise { const id = globalThis.crypto.randomUUID(); + try { const account = await this.#accountsService.create(id, options); diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 1711421f..062e3e8b 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -1,7 +1,7 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import { assert, integer, pattern, string } from '@metamask/superstruct'; +import { assert, pattern, string } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import { hexToBytes } from '@metamask/utils'; import { TronWeb } from 'tronweb'; @@ -131,23 +131,11 @@ export class AccountsService { async deriveAccount({ entropySource, index, - derivationPath: customDerivationPath, }: { entropySource: EntropySourceId; - index?: number; - derivationPath?: `m/${string}`; + index: number; }): Promise { - const derivationPath = - customDerivationPath ?? - (index === undefined ? undefined : this.#getDefaultDerivationPath(index)); - - if (derivationPath === undefined) { - throw new Error('Either index or derivationPath must be provided'); - } - - const accountIndex = - index ?? this.#getIndexFromDerivationPath(derivationPath); - + const derivationPath = AccountsService.getDefaultDerivationPath(index); const { address } = await this.deriveTronKeypair({ entropySource, derivationPath, @@ -157,7 +145,7 @@ export class AccountsService { id: '', entropySource, derivationPath, - index: accountIndex, + index, type: TrxAccountType.Eoa, address, scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], @@ -166,7 +154,7 @@ export class AccountsService { type: 'mnemonic', id: entropySource, derivationPath, - groupIndex: accountIndex, + groupIndex: index, }, exportable: true, }, @@ -182,13 +170,9 @@ export class AccountsService { const entropySource = options?.entropySource ?? (await this.#getDefaultEntropySource()); - - const index = options?.derivationPath - ? this.#getIndexFromDerivationPath(options.derivationPath) - : this.#getLowestUnusedKeyringAccountIndex(accounts, entropySource); - - const derivationPath = - options?.derivationPath ?? this.#getDefaultDerivationPath(index); + const index = + options?.groupIndex ?? + this.#getLowestUnusedKeyringAccountIndex(accounts, entropySource); /** * Now that we have the `entropySource` and `derivationPath` ready, @@ -196,8 +180,7 @@ export class AccountsService { */ const sameAccount = accounts.find( (account) => - account.derivationPath === derivationPath && - account.entropySource === entropySource, + account.index === index && account.entropySource === entropySource, ); if (sameAccount) { @@ -210,15 +193,9 @@ export class AccountsService { const derivedAccount = await this.deriveAccount({ entropySource, index, - derivationPath, }); - const { - importedAccount, - accountNameSuggestion, - metamask: metamaskOptions, - ...remainingOptions - } = options ?? {}; + const { metamask: metamaskOptions, ...remainingOptions } = options ?? {}; const tronKeyringAccount: TronKeyringAccount = { ...derivedAccount, @@ -249,9 +226,6 @@ export class AccountsService { * and the snaps sdk does not allow extra properties. */ account: keyringAccount, - accountNameSuggestion: - accountNameSuggestion ?? `Tron Account ${index + 1}`, - displayAccountNameSuggestion: !accountNameSuggestion, /** * Skip account creation confirmation dialogs to make it look like a native * account creation flow. @@ -373,24 +347,10 @@ export class AccountsService { return getLowestUnusedIndex(accountsFilteredByEntropySourceId); } - #getDefaultDerivationPath(index: number): `m/${string}` { + static getDefaultDerivationPath(index: number): `m/${string}` { return `m/44'/195'/0'/0/${index}`; } - #getIndexFromDerivationPath(derivationPath: `m/${string}`): number { - const levels = derivationPath.split('/'); - const indexLevel = levels[3]; - - if (!indexLevel) { - throw new Error('Invalid derivation path'); - } - - const index = parseInt(indexLevel.replace("'", ''), 10); - assert(index, integer()); - - return index; - } - async #getDefaultEntropySource(): Promise { const entropySources = await this.#snapClient.listEntropySources(); const defaultEntropySource = entropySources.find(({ primary }) => primary); diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/types.ts b/merged-packages/tron-wallet-snap/src/services/accounts/types.ts index 2f30c482..fc50bf1c 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/types.ts @@ -3,7 +3,6 @@ import type { Json } from '@metamask/utils'; export type CreateAccountOptions = { entropySource?: EntropySourceId; - derivationPath?: `m/${string}`; - accountNameSuggestion?: string; + groupIndex?: number; [key: string]: Json | undefined; } & MetaMaskOptions; From 297e6fb189510aab8d4a23d66be9db7ba2c73e5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:36:58 +0100 Subject: [PATCH 048/238] 1.5.0 (#53) This is the release candidate for version 1.5.0. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 14 +++++++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 88bda0a6..05022b1c 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.0] + +### Added + +- Implement staking and unstaking handlers ([#46](https://github.com/MetaMask/snap-tron-wallet/pull/46)) + +### Changed + +- Match the new Keyring `createAccount` spec ([#52](https://github.com/MetaMask/snap-tron-wallet/pull/52)) +- Implement safe error handling so that the Snap never crashes ([#51](https://github.com/MetaMask/snap-tron-wallet/pull/51)) + ## [1.4.0] ### Fixed @@ -75,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.4.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.0...HEAD +[1.5.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.1.1...v1.2.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 1fdcae0b..714d6918 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.4.0", + "version": "1.5.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", From e1e1b18a4f93b39c6494241c94c26f42e1d3b866 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 21 Oct 2025 11:23:11 +0100 Subject: [PATCH 049/238] fix: modify `signAndSendTransaction` to properly handle base64 transactions (#54) --- .../tron-wallet-snap/snap.manifest.json | 4 ++-- .../src/handlers/clientRequest/clientRequest.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 2424baa6..945c4896 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.4.0", + "version": "1.5.0", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "UIdglN3XU5zjZSVvyuXgVyfURh91x6rq8yO62Nr1704=", + "shasum": "l8mh1Ait4LqaYVe+2Ni5S3hhM07GhJaKWaOp7Pxuvrg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 9220cb18..1917aa66 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -140,7 +140,10 @@ export class ClientRequestHandler { new InvalidParamsError(), ); - const { transaction, accountId, scope } = request.params; + /** + * Transaction here is in base64 format. + */ + const { transaction: transactionBase64, accountId, scope } = request.params; const account = await this.#accountsService.findByIdOrThrow(accountId); @@ -153,7 +156,14 @@ export class ClientRequestHandler { const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); - const signedTx = await tronWeb.trx.sign(transaction); + /** + * `sign` expects either a Transaction object or a hex string. + */ + // eslint-disable-next-line no-restricted-globals + const transactionHex = Buffer.from(transactionBase64, 'base64').toString( + 'hex', + ); + const signedTx = await tronWeb.trx.sign(transactionHex); const result = await tronWeb.trx.sendHexTransaction(signedTx); /** From ca3c6473a901c6aef0d148bb96d0efed83dbcf15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Tue, 21 Oct 2025 15:23:45 +0100 Subject: [PATCH 050/238] chore: update max bandwidth and energy values (#55) Add `maximunEnergyTokensMetadata` and `maximunBandwidthTokensMetadata`. --- .../src/services/assets/AssetsService.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 96cddcff..5e49d5c0 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -34,6 +34,8 @@ import type { TronAccount } from '../../clients/trongrid/types'; import { BANDWIDTH_METADATA, ENERGY_METADATA, + MAX_ENERGY_METADATA, + MAX_BANDWIDTH_METADATA, Networks, TRX_METADATA, TRX_STAKED_FOR_BANDWIDTH_METADATA, @@ -446,7 +448,9 @@ export class AssetsService { nativeAssetTypes, stakedNativeAssetTypes, energyAssetTypes, + maximunEnergyAssetTypes, bandwidthAssetTypes, + maximunBandwidthAssetTypes, tokenTrc10AssetTypes, tokenTrc20AssetTypes, } = this.#splitAssetsByType(assetTypes); @@ -455,13 +459,17 @@ export class AssetsService { nativeTokensMetadata, stakedTokensMetadata, energyTokensMetadata, + maximunEnergyTokensMetadata, bandwidthTokensMetadata, + maximunBandwidthTokensMetadata, tokensMetadata, ] = await Promise.all([ this.#getNativeTokensMetadata(nativeAssetTypes), this.#getStakedTokensMetadata(stakedNativeAssetTypes), this.#getEnergyMetadata(energyAssetTypes), + this.#getMaximunEnergyMetadata(maximunEnergyAssetTypes), this.#getBandwidthMetadata(bandwidthAssetTypes), + this.#getMaximunBandwidthMetadata(maximunBandwidthAssetTypes), this.#getTokensMetadata([ ...tokenTrc10AssetTypes, ...tokenTrc20AssetTypes, @@ -472,7 +480,9 @@ export class AssetsService { ...nativeTokensMetadata, ...stakedTokensMetadata, ...energyTokensMetadata, + ...maximunEnergyTokensMetadata, ...bandwidthTokensMetadata, + ...maximunBandwidthTokensMetadata, ...tokensMetadata, }; @@ -485,7 +495,9 @@ export class AssetsService { nativeAssetTypes: NativeCaipAssetType[]; stakedNativeAssetTypes: StakedCaipAssetType[]; energyAssetTypes: ResourceCaipAssetType[]; + maximunEnergyAssetTypes: ResourceCaipAssetType[]; bandwidthAssetTypes: ResourceCaipAssetType[]; + maximunBandwidthAssetTypes: ResourceCaipAssetType[]; tokenTrc10AssetTypes: TokenCaipAssetType[]; tokenTrc20AssetTypes: TokenCaipAssetType[]; nftAssetTypes: NftCaipAssetType[]; @@ -499,9 +511,15 @@ export class AssetsService { const energyAssetTypes = assetTypes.filter((assetType) => assetType.endsWith('/slip44:energy'), ) as ResourceCaipAssetType[]; + const maximunEnergyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-energy'), + ) as ResourceCaipAssetType[]; const bandwidthAssetTypes = assetTypes.filter((assetType) => assetType.endsWith('/slip44:bandwidth'), ) as ResourceCaipAssetType[]; + const maximunBandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-bandwidth'), + ) as ResourceCaipAssetType[]; const tokenTrc10AssetTypes = assetTypes.filter((assetType) => assetType.includes('/trc10:'), ) as TokenCaipAssetType[]; @@ -516,7 +534,9 @@ export class AssetsService { nativeAssetTypes, stakedNativeAssetTypes, energyAssetTypes, + maximunEnergyAssetTypes, bandwidthAssetTypes, + maximunBandwidthAssetTypes, tokenTrc10AssetTypes, tokenTrc20AssetTypes, nftAssetTypes, @@ -627,6 +647,33 @@ export class AssetsService { return bandwidthTokensMetadata; } + #getMaximunBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunBandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunBandwidthTokensMetadata[assetType] = { + fungible: MAX_BANDWIDTH_METADATA.fungible, + name: MAX_BANDWIDTH_METADATA.name, + symbol: MAX_BANDWIDTH_METADATA.symbol, + iconUrl: MAX_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: MAX_BANDWIDTH_METADATA.decimals, + symbol: MAX_BANDWIDTH_METADATA.symbol, + name: MAX_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return maximunBandwidthTokensMetadata; + } + #getEnergyMetadata( assetTypes: ResourceCaipAssetType[], ): Record { @@ -654,6 +701,33 @@ export class AssetsService { return energyTokensMetadata; } + #getMaximunEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunEnergyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunEnergyTokensMetadata[assetType] = { + fungible: MAX_ENERGY_METADATA.fungible, + name: MAX_ENERGY_METADATA.name, + symbol: MAX_ENERGY_METADATA.symbol, + iconUrl: MAX_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: MAX_ENERGY_METADATA.decimals, + symbol: MAX_ENERGY_METADATA.symbol, + name: MAX_ENERGY_METADATA.name, + }, + ], + }; + } + + return maximunEnergyTokensMetadata; + } + async #getTokensMetadata( assetTypes: TokenCaipAssetType[], ): Promise> { From 55ef941de8ec12e369cd5f0ef1789ca1f35a7432 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:42:30 +0100 Subject: [PATCH 051/238] 1.5.1 (#56) This is the release candidate for version 1.5.1. --------- Co-authored-by: github-actions Co-authored-by: Antonio Regadas --- merged-packages/tron-wallet-snap/CHANGELOG.md | 10 +++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 05022b1c..1a352206 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1] + +### Changed + +- Send the metadata for the max bandwidth and energy values ([#55](https://github.com/MetaMask/snap-tron-wallet/pull/55)) +- Modify `signAndSendTransaction` to properly handle base64 transactions ([#54](https://github.com/MetaMask/snap-tron-wallet/pull/54)) + ## [1.5.0] ### Added @@ -86,7 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.1...HEAD +[1.5.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.2.0...v1.3.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 714d6918..232b3ff7 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.5.0", + "version": "1.5.1", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 945c4896..2c40b44e 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.5.0", + "version": "1.5.1", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "l8mh1Ait4LqaYVe+2Ni5S3hhM07GhJaKWaOp7Pxuvrg=", + "shasum": "eXinSTPTvrP0nGSCOH1sTWyTowEG8IlhX/kdzKmSK3w=", "location": { "npm": { "filePath": "dist/bundle.js", From fcd61de034fb04743ef7f7082ee492f00c3bcce2 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 24 Oct 2025 13:44:46 +0100 Subject: [PATCH 052/238] fix: missing fields on `signAndSendTransaction`'s payload for Tron (#57) --- merged-packages/tron-wallet-snap/package.json | 1 + .../handlers/clientRequest/clientRequest.ts | 29 +++++++++++++++---- .../src/handlers/clientRequest/validation.ts | 5 +++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 232b3ff7..9c10aedd 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -49,6 +49,7 @@ "bignumber.js": "^9.3.1", "concurrently": "^9.2.0", "dotenv": "^17.0.0", + "ethers": "^6.15.0", "jest": "^30.0.3", "jest-transform-stub": "2.0.0", "lodash": "^4.17.21", diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 1917aa66..3d794bb7 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -3,6 +3,7 @@ import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; import { assert } from '@metamask/superstruct'; import { BigNumber } from 'bignumber.js'; +import { sha256 } from 'ethers'; import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; @@ -143,7 +144,12 @@ export class ClientRequestHandler { /** * Transaction here is in base64 format. */ - const { transaction: transactionBase64, accountId, scope } = request.params; + const { + transaction: transactionBase64, + accountId, + scope, + options: { type }, + } = request.params; const account = await this.#accountsService.findByIdOrThrow(accountId); @@ -157,14 +163,25 @@ export class ClientRequestHandler { const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); /** - * `sign` expects either a Transaction object or a hex string. + * We need to rebuild the transaction due to some extra fields */ // eslint-disable-next-line no-restricted-globals - const transactionHex = Buffer.from(transactionBase64, 'base64').toString( - 'hex', + const rawDataHex = Buffer.from(transactionBase64, 'base64').toString('hex'); + const rawData = tronWeb.utils.transaction.DeserializeTransaction( + type, + rawDataHex, ); - const signedTx = await tronWeb.trx.sign(transactionHex); - const result = await tronWeb.trx.sendHexTransaction(signedTx); + const txID = sha256(`0x${rawDataHex}`).slice(2); + const transaction = { + txID, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: rawData, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: rawDataHex, + visible: true, + }; + const signedTx = await tronWeb.trx.sign(transaction); + const result = await tronWeb.trx.sendRawTransaction(signedTx); /** * Sync account after a transaction diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index 47babf58..5b89f6b0 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -1,7 +1,7 @@ import { AssetStruct, FeeType } from '@metamask/keyring-api'; import { literal } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; -import { array, boolean, enums, object } from '@metamask/superstruct'; +import { array, boolean, enums, object, string } from '@metamask/superstruct'; import { CaipAssetTypeStruct, JsonRpcIdStruct, @@ -28,6 +28,9 @@ export const SignAndSendTransactionRequestParamsStruct = object({ transaction: Base64Struct, accountId: UuidStruct, scope: ScopeStringStruct, + options: object({ + type: string(), + }), }); export const SignAndSendTransactionRequestStruct = object({ From 873e67672586953fcd845b09ccea662afd234887 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:01:11 +0100 Subject: [PATCH 053/238] 1.5.2 (#58) This is the release candidate for version 1.5.2. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 1a352206..a5efd722 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.2] + +### Fixed + +- Add missing fields on `signAndSendTransaction`'s payload for Tron ([#57](https://github.com/MetaMask/snap-tron-wallet/pull/57)) + ## [1.5.1] ### Changed @@ -93,7 +99,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.2...HEAD +[1.5.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.1...v1.5.2 [1.5.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.3.0...v1.4.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 9c10aedd..934a6fe6 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.5.1", + "version": "1.5.2", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 2c40b44e..1b2b757e 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.5.1", + "version": "1.5.2", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "eXinSTPTvrP0nGSCOH1sTWyTowEG8IlhX/kdzKmSK3w=", + "shasum": "t1+i6NF+TuK+pWYc0gEHj5zRzjSldELpy3KQ8PkpSAQ=", "location": { "npm": { "filePath": "dist/bundle.js", From b90fbc21e031687f0e153d1253f28a30f258b6b4 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 24 Oct 2025 14:54:54 +0100 Subject: [PATCH 054/238] fix: make field `visible` configurable by caller on the `signAndSendTransaction` handler (#59) --- .../src/handlers/clientRequest/clientRequest.ts | 4 ++-- .../tron-wallet-snap/src/handlers/clientRequest/validation.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 3d794bb7..cb413af4 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -148,7 +148,7 @@ export class ClientRequestHandler { transaction: transactionBase64, accountId, scope, - options: { type }, + options: { visible, type }, } = request.params; const account = await this.#accountsService.findByIdOrThrow(accountId); @@ -178,7 +178,7 @@ export class ClientRequestHandler { raw_data: rawData, // eslint-disable-next-line @typescript-eslint/naming-convention raw_data_hex: rawDataHex, - visible: true, + visible, }; const signedTx = await tronWeb.trx.sign(transaction); const result = await tronWeb.trx.sendRawTransaction(signedTx); diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index 5b89f6b0..4fa80167 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -29,6 +29,7 @@ export const SignAndSendTransactionRequestParamsStruct = object({ accountId: UuidStruct, scope: ScopeStringStruct, options: object({ + visible: boolean(), type: string(), }), }); From 0953a8b83deed170f08b918526c51f187e30a815 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:10:02 +0100 Subject: [PATCH 055/238] 1.5.3 (#60) This is the release candidate for version 1.5.3. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index a5efd722..b902e7ca 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.3] + +### Fixed + +- Make field `visible` configurable by caller on the `signAndSendTransaction` handler ([#59](https://github.com/MetaMask/snap-tron-wallet/pull/59)) + ## [1.5.2] ### Fixed @@ -99,7 +105,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.2...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.3...HEAD +[1.5.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.2...v1.5.3 [1.5.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.1...v1.5.2 [1.5.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.4.0...v1.5.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 934a6fe6..ab6a5f89 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.5.2", + "version": "1.5.3", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 1b2b757e..7c387fc0 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.5.2", + "version": "1.5.3", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "t1+i6NF+TuK+pWYc0gEHj5zRzjSldELpy3KQ8PkpSAQ=", + "shasum": "knbps5UEsd5DdZnXjInGWPOjOH9Rc/SQSkX25yGFdQ4=", "location": { "npm": { "filePath": "dist/bundle.js", From b0aada4de9dde82659984da9632f531edd9b9b3e Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 28 Oct 2025 17:21:28 +0000 Subject: [PATCH 056/238] fix: use correct hex private key for `signAndSendTransaction` (#61) --- merged-packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/clientRequest/clientRequest.ts | 6 ++---- .../src/services/accounts/AccountsService.ts | 13 ++++--------- .../src/services/send/SendService.ts | 12 +++--------- .../src/services/staking/StakingService.test.ts | 2 +- .../src/services/staking/StakingService.ts | 8 ++++---- 6 files changed, 15 insertions(+), 28 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 7c387fc0..19085a3d 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "knbps5UEsd5DdZnXjInGWPOjOH9Rc/SQSkX25yGFdQ4=", + "shasum": "s4Xmpx6ymA1ubwO0UP7oc6bX1147zF2gJ9gkjIdFM4Q=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index cb413af4..a9c6d158 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -153,13 +153,11 @@ export class ClientRequestHandler { const account = await this.#accountsService.findByIdOrThrow(accountId); - const keypair = await this.#accountsService.deriveTronKeypair({ + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, derivationPath: account.derivationPath, }); - // eslint-disable-next-line no-restricted-globals - const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); /** @@ -173,12 +171,12 @@ export class ClientRequestHandler { ); const txID = sha256(`0x${rawDataHex}`).slice(2); const transaction = { + visible, txID, // eslint-disable-next-line @typescript-eslint/naming-convention raw_data: rawData, // eslint-disable-next-line @typescript-eslint/naming-convention raw_data_hex: rawDataHex, - visible, }; const signedTx = await tronWeb.trx.sign(transaction); const result = await tronWeb.trx.sendRawTransaction(signedTx); diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 062e3e8b..16c207e1 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -74,14 +74,8 @@ export class AccountsService { * @param params - The parameters for the TRON key derivation. * @param params.entropySource - The entropy source to use for key derivation. * @param params.derivationPath - The derivation path to use for key derivation. - * @returns A Promise that resolves to the private key, public key, and address. + * @returns A Promise that resolves to the private key bytes, public key bytes, private key hex WITHOUT the `0x` prefix, and address. * @throws {Error} If unable to derive private key or if derivation fails. - * @example - * ```typescript - * const { privateKeyBytes, publicKeyBytes, address } = await deriveTronKeypair({ - * derivationPath: "m/44'/195'/0'/0/0" - * }); - * ``` */ async deriveTronKeypair({ entropySource, @@ -113,8 +107,9 @@ export class AccountsService { const privateKeyBytes = hexToBytes(node.privateKey); const publicKeyBytes = hexToBytes(node.publicKey); + const privateKeyHex = node.privateKey.slice(2); - const address = TronWeb.address.fromPrivateKey(node.privateKey.slice(2)); + const address = TronWeb.address.fromPrivateKey(privateKeyHex); if (!address) { throw new Error('Unable to derive address'); @@ -123,7 +118,7 @@ export class AccountsService { return { privateKeyBytes, publicKeyBytes, - privateKeyHex: node.privateKey, + privateKeyHex, address, }; } diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts index 92a41f1a..8aaa8ed8 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -104,13 +104,11 @@ export class SendService { }): Promise { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - const keypair = await this.#accountsService.deriveTronKeypair({ + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, derivationPath: account.derivationPath, }); - // eslint-disable-next-line no-restricted-globals - const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); try { @@ -180,13 +178,11 @@ export class SendService { }): Promise { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - const keypair = await this.#accountsService.deriveTronKeypair({ + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, derivationPath: account.derivationPath, }); - // eslint-disable-next-line no-restricted-globals - const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); try { @@ -256,13 +252,11 @@ export class SendService { }): Promise { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - const keypair = await this.#accountsService.deriveTronKeypair({ + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, derivationPath: account.derivationPath, }); - // eslint-disable-next-line no-restricted-globals - const privateKeyHex = Buffer.from(keypair.privateKeyBytes).toString('hex'); const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); try { diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts index 0fae4990..d49f5981 100644 --- a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts @@ -30,7 +30,7 @@ describe('StakingService', () => { const mockKeypair = { privateKeyHex: - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', // eslint-disable-next-line no-restricted-globals privateKeyBytes: Buffer.from( '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts index b874a5a2..c859c59d 100644 --- a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts @@ -55,14 +55,14 @@ export class StakingService { const { chainId } = parseCaipAssetType(assetId); - const keypair = await this.#accountsService.deriveTronKeypair({ + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, derivationPath: account.derivationPath, }); const tronWeb = this.#tronWebFactory.createClient( chainId as Network, - keypair.privateKeyHex, + privateKeyHex, ); const transaction = await tronWeb.transactionBuilder.freezeBalanceV2( @@ -98,14 +98,14 @@ export class StakingService { const { chainId } = parseCaipAssetType(assetId); - const keypair = await this.#accountsService.deriveTronKeypair({ + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ entropySource: account.entropySource, derivationPath: account.derivationPath, }); const tronWeb = this.#tronWebFactory.createClient( chainId as Network, - keypair.privateKeyHex, + privateKeyHex, ); /** From 8e44079ee5787a5c7da0a701da1e32ed3e970ffb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:39:56 +0000 Subject: [PATCH 057/238] 1.5.4 (#62) This is the release candidate for version 1.5.4. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index b902e7ca..5d1ea196 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.4] + +### Fixed + +- Use the correct hexadecimal format private key (excluding the `0x` prefix) when using TronWeb ([#61](https://github.com/MetaMask/snap-tron-wallet/pull/61)) + ## [1.5.3] ### Fixed @@ -105,7 +111,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.3...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.4...HEAD +[1.5.4]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.3...v1.5.4 [1.5.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.2...v1.5.3 [1.5.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.1...v1.5.2 [1.5.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.0...v1.5.1 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index ab6a5f89..99b7e669 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.5.3", + "version": "1.5.4", "description": "A Tron wallet Snap.", "repository": { "type": "git", From 5738630f1a7d61f2d58d637ad12727e15780084d Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Oct 2025 14:10:42 +0000 Subject: [PATCH 058/238] feat: implement `setSelectedAccounts` handler (#63) --- merged-packages/tron-wallet-snap/package.json | 2 +- .../tron-wallet-snap/snap.manifest.json | 4 +- .../tron-wallet-snap/src/handlers/cronjob.ts | 44 ++++++++++++++----- .../tron-wallet-snap/src/handlers/keyring.ts | 17 +++++++ .../src/handlers/lifecycle.ts | 2 +- .../tron-wallet-snap/src/permissions.ts | 13 ++++++ .../services/accounts/AccountsRepository.ts | 5 +++ .../src/services/accounts/AccountsService.ts | 28 +++++++++++- 8 files changed, 97 insertions(+), 18 deletions(-) diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 99b7e669..4e8e8bf4 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -38,7 +38,7 @@ "devDependencies": { "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^21.1.0", - "@metamask/keyring-snap-sdk": "^4.0.0", + "@metamask/keyring-snap-sdk": "^7.1.0", "@metamask/snaps-cli": "^8.1.0", "@metamask/snaps-jest": "^9.2.0", "@metamask/snaps-sdk": "^9.3.0", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 19085a3d..30d773bc 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.5.3", + "version": "1.5.4", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "s4Xmpx6ymA1ubwO0UP7oc6bX1147zF2gJ9gkjIdFM4Q=", + "shasum": "ZmQoShApZ3uy4l7PUM+1k0QkWAMd6r9iIFECj0fBZCU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts index d829aacb..11e09fd8 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts @@ -6,7 +6,8 @@ import type { ILogger } from '../utils/logger'; import { createPrefixedLogger } from '../utils/logger'; export enum BackgroundEventMethod { - ContinuouslySynchronizeAccounts = 'onContinuouslySynchronizeAccounts', + ContinuouslySynchronizeSelectedAccounts = 'onContinuouslySynchronizeAccounts', + SynchronizeSelectedAccounts = 'onSynchronizeSelectedAccounts', SynchronizeAccounts = 'onSynchronizeAccounts', SynchronizeAccount = 'onSynchronizeAccount', SynchronizeAccountTransactions = 'onSynchronizeAccountTransactions', @@ -42,11 +43,14 @@ export class CronHandler { } switch (method as BackgroundEventMethod) { - case BackgroundEventMethod.ContinuouslySynchronizeAccounts: - await this.continuouslySynchronizeAccounts(); + case BackgroundEventMethod.ContinuouslySynchronizeSelectedAccounts: + await this.continuouslySynchronizeSelectedAccounts(); + break; + case BackgroundEventMethod.SynchronizeSelectedAccounts: + await this.synchronizeSelectedAccounts(); break; case BackgroundEventMethod.SynchronizeAccounts: - await this.synchronizeAccounts(); + await this.synchronizeAccounts(params as { accountIds: string[] }); break; case BackgroundEventMethod.SynchronizeAccount: await this.synchronizeAccount(params as { accountId: string }); @@ -62,24 +66,40 @@ export class CronHandler { } /** - * A background job that continuosly synchronizes all accounts. + * A background job that continuously synchronizes selected accounts. * It schedules itself while the extension is active to make sure the data is fresh. */ - async continuouslySynchronizeAccounts(): Promise { - this.#logger.info('[Tick] Continuously synchronizing accounts...'); + async continuouslySynchronizeSelectedAccounts(): Promise { + this.#logger.info('[Tick] Continuously synchronizing selected accounts...'); - await this.synchronizeAccounts(); + await this.synchronizeSelectedAccounts(); await this.#snapClient.scheduleBackgroundEvent({ - method: BackgroundEventMethod.ContinuouslySynchronizeAccounts, + method: BackgroundEventMethod.ContinuouslySynchronizeSelectedAccounts, duration: '30s', }); } - async synchronizeAccounts(): Promise { - this.#logger.info('Synchronizing all accounts...'); + async synchronizeSelectedAccounts(): Promise { + this.#logger.info('Synchronizing selected accounts...'); + + const accounts = await this.#accountsService.getAllSelected(); + + await this.#accountsService.synchronize(accounts); + } - const accounts = await this.#accountsService.getAll(); + async synchronizeAccounts({ + accountIds, + }: { + accountIds: string[]; + }): Promise { + this.#logger.info(`Synchronizing accounts ${accountIds.join(', ')}...`); + + const accounts = await this.#accountsService.findByIds(accountIds); + + if (!accounts) { + return; + } await this.#accountsService.synchronize(accounts); } diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index fa492302..66c5c7b4 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -19,6 +19,7 @@ import { handleKeyringRequest, } from '@metamask/keyring-snap-sdk'; import { SnapError } from '@metamask/snaps-sdk'; +import { array } from '@metamask/superstruct'; import type { CaipAssetType, CaipAssetTypeOrId, @@ -43,6 +44,7 @@ import { GetAccounBalancesResponseStruct, GetAccountBalancesStruct, ListAccountAssetsStruct, + UuidStruct, } from '../validation/structs'; import { validateOrigin, @@ -362,4 +364,19 @@ export class KeyringHandler implements Keyring { rejectRequest?(id: string): Promise { throw new Error('Method not implemented.'); } + + /** + * Endpoint that the client can use to inform the snap that certain accounts are selected. + * + * @param accountIds - The IDs of the accounts to set as selected. + */ + async setSelectedAccounts(accountIds: string[]): Promise { + validateRequest(accountIds, array(UuidStruct)); + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeSelectedAccounts, + params: { accountIds }, + duration: 'PT1S', + }); + } } diff --git a/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts b/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts index f2f34666..b7c1c0ca 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts @@ -26,7 +26,7 @@ export class LifecycleHandler { this.#logger.log('[🔋 onActive]'); await this.#snapClient.scheduleBackgroundEvent({ - method: BackgroundEventMethod.ContinuouslySynchronizeAccounts, + method: BackgroundEventMethod.ContinuouslySynchronizeSelectedAccounts, duration: '1s', }); } diff --git a/merged-packages/tron-wallet-snap/src/permissions.ts b/merged-packages/tron-wallet-snap/src/permissions.ts index dc50f409..a1d0729f 100644 --- a/merged-packages/tron-wallet-snap/src/permissions.ts +++ b/merged-packages/tron-wallet-snap/src/permissions.ts @@ -1,5 +1,7 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; +import { ClientRequestMethod } from './handlers/clientRequest/types'; + const prodOrigins = ['https://portfolio.metamask.io']; const isDev = true; // TODO: Change me when we have a config provider @@ -33,6 +35,17 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.ListAccountTransactions, KeyringRpcMethod.ListAccountAssets, KeyringRpcMethod.ResolveAccountAddress, + KeyringRpcMethod.SetSelectedAccounts, + // Client request + ClientRequestMethod.SignAndSendTransaction, + ClientRequestMethod.ConfirmSend, + ClientRequestMethod.ComputeFee, + ClientRequestMethod.OnAddressInput, + ClientRequestMethod.OnAmountInput, + ClientRequestMethod.OnStakeAmountInput, + ClientRequestMethod.ConfirmStake, + ClientRequestMethod.OnUnstakeAmountInput, + ClientRequestMethod.ConfirmUnstake, ]); const metamask = 'metamask'; diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts index 32e4e237..9c869871 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts @@ -29,6 +29,11 @@ export class AccountsRepository { return accounts.find((account) => account.id === id) ?? null; } + async findByIds(ids: string[]): Promise { + const accounts = await this.getAll(); + return accounts.filter((account) => ids.includes(account.id)) ?? null; + } + async findByAddress(address: string): Promise { const accounts = await this.getAll(); diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 16c207e1..b82ef1e1 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -1,6 +1,9 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { + emitSnapKeyringEvent, + getSelectedAccounts, +} from '@metamask/keyring-snap-sdk'; import { assert, pattern, string } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import { hexToBytes } from '@metamask/utils'; @@ -170,7 +173,7 @@ export class AccountsService { this.#getLowestUnusedKeyringAccountIndex(accounts, entropySource); /** - * Now that we have the `entropySource` and `derivationPath` ready, + * Now that we have the `entropySource` and `index` ready, * we need to make sure that they do not correspond to an existing account already. */ const sameAccount = accounts.find( @@ -244,6 +247,17 @@ export class AccountsService { return this.#accountsRepository.getAll(); } + async getAllSelected(): Promise { + const [allAccounts, selectedAccountIds] = await Promise.all([ + this.#accountsRepository.getAll(), + getSelectedAccounts(snap), + ]); + + return allAccounts.filter((account) => + selectedAccountIds.includes(account.id), + ); + } + async findById(id: string): Promise { return this.#accountsRepository.findById(id); } @@ -266,6 +280,16 @@ export class AccountsService { return account; } + async findByIds(ids: string[]): Promise { + const accounts = await this.#accountsRepository.findByIds(ids); + + if (!accounts || ids.length !== accounts.length) { + this.#logger.error('[findByIds] Some accounts not found'); + } + + return accounts; + } + async findByAddress(address: string): Promise { return this.#accountsRepository.findByAddress(address); } From 9708593105c750e639e29bebb1b42a9456b85d22 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Oct 2025 14:10:59 +0000 Subject: [PATCH 059/238] fix: adjust `timestamp` fields' precision in seconds, not milliseconds (#64) --- .../transactions/TransactionsMapper.test.ts | 16 ++++++++-------- .../services/transactions/TransactionsMapper.ts | 16 ++++++++++------ .../transactions/TransactionsService.test.ts | 16 ++++++++-------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts index 287962bb..abe72aa0 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts @@ -68,11 +68,11 @@ describe('TransactionMapper', () => { ], chain: 'tron:728126428', status: 'confirmed', - timestamp: 1756914747000, + timestamp: 1756914747, events: [ { status: 'confirmed', - timestamp: 1756914747000, + timestamp: 1756914747, }, ], fees: [ @@ -142,11 +142,11 @@ describe('TransactionMapper', () => { ], chain: 'tron:728126428', status: 'confirmed', - timestamp: 1756870677000, + timestamp: 1756870677, events: [ { status: 'confirmed', - timestamp: 1756870677000, + timestamp: 1756870677, }, ], fees: [ @@ -212,11 +212,11 @@ describe('TransactionMapper', () => { ], chain: 'tron:728126428', status: 'confirmed', - timestamp: 1757590707000, + timestamp: 1757590707, events: [ { status: 'confirmed', - timestamp: 1757590707000, + timestamp: 1757590707, }, ], fees: [ @@ -489,11 +489,11 @@ describe('TransactionMapper', () => { ], chain: 'tron:2494104990', status: 'confirmed', - timestamp: 1756914747000, + timestamp: 1756914747, events: [ { status: 'confirmed', - timestamp: 1756914747000, + timestamp: 1756914747, }, ], fees: [ diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts index 044e011f..e48a6bba 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts @@ -85,6 +85,8 @@ export class TransactionMapper { const from = TronWeb.address.fromHex(contractValue.owner_address); const to = TronWeb.address.fromHex(contractValue.to_address); + const timestamp = Math.floor(trongridTransaction.block_timestamp / 1000); + // Convert from sun to TRX (divide by 10^6) const amountInSun = contractValue.amount; const amountInTrx = (amountInSun / 1_000_000).toString(); @@ -135,13 +137,13 @@ export class TransactionMapper { events: [ { status: 'confirmed', - timestamp: trongridTransaction.block_timestamp, + timestamp, }, ], chain: scope, status: 'confirmed', account: account.id, - timestamp: trongridTransaction.block_timestamp, + timestamp, fees, }; } @@ -170,6 +172,8 @@ export class TransactionMapper { const from = TronWeb.address.fromHex(contractValue.owner_address); const to = TronWeb.address.fromHex(contractValue.to_address); + const timestamp = Math.floor(trongridTransaction.block_timestamp / 1000); + // Convert from smallest unit to human-readable amount (TRC10 typically uses 6 decimals) const amountInSmallestUnit = contractValue.amount; const amountInReadableUnit = (amountInSmallestUnit / 1_000_000).toString(); @@ -219,13 +223,13 @@ export class TransactionMapper { events: [ { status: 'confirmed', - timestamp: trongridTransaction.block_timestamp, + timestamp, }, ], chain: scope, status: 'confirmed', account: account.id, - timestamp: trongridTransaction.block_timestamp, + timestamp, fees, }; } @@ -312,13 +316,13 @@ export class TransactionMapper { events: [ { status: 'confirmed', - timestamp: trc20AssistanceData.block_timestamp, + timestamp: Math.floor(trc20AssistanceData.block_timestamp / 1000), }, ], chain: scope, status: 'confirmed', account: account.id, - timestamp: trc20AssistanceData.block_timestamp, + timestamp: Math.floor(trc20AssistanceData.block_timestamp / 1000), fees, }; } diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts index c9669600..946f1a37 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts @@ -222,7 +222,7 @@ describe('TransactionsService', () => { account: mockAccount.id, chain: Network.Mainnet, status: 'confirmed', - timestamp: Date.now(), + timestamp: Math.floor(Date.now() / 1000), from: [ { address: mockAccount.address, @@ -257,7 +257,7 @@ describe('TransactionsService', () => { account: mockAccount2.id, chain: Network.Mainnet, status: 'confirmed', - timestamp: Date.now(), + timestamp: Math.floor(Date.now() / 1000), from: [ { address: 'other-address', @@ -330,7 +330,7 @@ describe('TransactionsService', () => { account: mockAccount.id, chain: Network.Mainnet, status: 'confirmed', - timestamp: Date.now(), + timestamp: Math.floor(Date.now() / 1000), from: [ { address: mockAccount.address, @@ -376,7 +376,7 @@ describe('TransactionsService', () => { account: mockAccount.id, chain: Network.Mainnet, status: 'confirmed', - timestamp: Date.now(), + timestamp: Math.floor(Date.now() / 1000), from: [ { address: mockAccount.address, @@ -408,7 +408,7 @@ describe('TransactionsService', () => { account: mockAccount.id, chain: Network.Mainnet, status: 'confirmed', - timestamp: Date.now(), + timestamp: Math.floor(Date.now() / 1000), from: [ { address: 'other-address', @@ -469,7 +469,7 @@ describe('TransactionsService', () => { account: mockAccount.id, chain: Network.Mainnet, status: 'confirmed', - timestamp: Date.now(), + timestamp: Math.floor(Date.now() / 1000), from: [ { address: mockAccount.address, @@ -501,7 +501,7 @@ describe('TransactionsService', () => { account: mockAccount.id, chain: Network.Mainnet, status: 'confirmed', - timestamp: Date.now(), + timestamp: Math.floor(Date.now() / 1000), from: [ { address: 'other-address', @@ -533,7 +533,7 @@ describe('TransactionsService', () => { account: mockAccount2.id, chain: Network.Mainnet, status: 'confirmed', - timestamp: Date.now(), + timestamp: Math.floor(Date.now() / 1000), from: [ { address: mockAccount2.address, From b5eb458476a84416591214e22eb7b0e108df0509 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Oct 2025 14:16:21 +0000 Subject: [PATCH 060/238] chore: bump shasum (#65) --- merged-packages/tron-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 30d773bc..6cda7023 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "ZmQoShApZ3uy4l7PUM+1k0QkWAMd6r9iIFECj0fBZCU=", + "shasum": "8A57BYMor2+QEf+hAISWY9m9cZ+5lBm9e++KrlCMLa4=", "location": { "npm": { "filePath": "dist/bundle.js", From d99cba3feabc6569e3f1d4ef1290c6b38304f088 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:22:43 +0000 Subject: [PATCH 061/238] 1.6.0 (#66) This is the release candidate for version 1.6.0. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 5d1ea196..751d157e 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.0] + +### Added + +- Implement `setSelectedAccounts` handler ([#63](https://github.com/MetaMask/snap-tron-wallet/pull/63)) + +### Fixed + +- Adjust `timestamp` fields' precision to be in seconds, not milliseconds ([#64](https://github.com/MetaMask/snap-tron-wallet/pull/64)) + ## [1.5.4] ### Fixed @@ -111,7 +121,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.4...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.0...HEAD +[1.6.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.4...v1.6.0 [1.5.4]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.3...v1.5.4 [1.5.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.2...v1.5.3 [1.5.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.1...v1.5.2 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 4e8e8bf4..7aad8ed9 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.5.4", + "version": "1.6.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", From 11f6c2909098b617f3095ded5005818793e3d42c Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Oct 2025 16:09:21 +0000 Subject: [PATCH 062/238] fix: use the correct `index` instead of `groupIndex` for account creation (#67) Also added some better handling for errors coming from Assets or Transactions fetching. We still log it but we don't let it bubble up to the handler's main entry point --- .../tron-wallet-snap/snap.manifest.json | 4 +- .../src/clients/trongrid/TrongridApiClient.ts | 12 +----- .../src/services/accounts/AccountsService.ts | 3 +- .../src/services/accounts/types.ts | 2 +- .../src/services/assets/AssetsService.ts | 41 +++++++++--------- .../transactions/TransactionsService.test.ts | 36 ++++++++++------ .../transactions/TransactionsService.ts | 42 ++++++++++++++----- 7 files changed, 83 insertions(+), 57 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 6cda7023..1ef4708e 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.5.4", + "version": "1.6.0", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "8A57BYMor2+QEf+hAISWY9m9cZ+5lBm9e++KrlCMLa4=", + "shasum": "m5ryTle3iXLbNSpb6yO0B7O7VPhuDr7aA4oSQjji5Bs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts index ece00e9e..40ede14b 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts @@ -113,14 +113,10 @@ export class TrongridApiClient { const rawData: TrongridApiResponse = await response.json(); - if (!rawData.success) { + if (!rawData.success || !rawData.data) { throw new Error('API request failed'); } - if (!rawData.data || rawData.data.length === 0) { - throw new Error('No transactions found'); - } - return rawData.data; } @@ -153,14 +149,10 @@ export class TrongridApiClient { const rawData: TrongridApiResponse = await response.json(); - if (!rawData.success) { + if (!rawData.success || !rawData.data) { throw new Error('API request failed'); } - if (!rawData.data || rawData.data.length === 0) { - throw new Error('No transactions found'); - } - return rawData.data; } diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index b82ef1e1..97ae5223 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -169,7 +169,7 @@ export class AccountsService { const entropySource = options?.entropySource ?? (await this.#getDefaultEntropySource()); const index = - options?.groupIndex ?? + options?.index ?? this.#getLowestUnusedKeyringAccountIndex(accounts, entropySource); /** @@ -205,6 +205,7 @@ export class AccountsService { ([, value]) => value !== undefined, ), ) as Record), + groupIndex: index, }, }; diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/types.ts b/merged-packages/tron-wallet-snap/src/services/accounts/types.ts index fc50bf1c..c25193b8 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/types.ts @@ -3,6 +3,6 @@ import type { Json } from '@metamask/utils'; export type CreateAccountOptions = { entropySource?: EntropySourceId; - groupIndex?: number; + index?: number; [key: string]: Json | undefined; } & MetaMaskOptions; diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 5e49d5c0..13de28de 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -34,8 +34,8 @@ import type { TronAccount } from '../../clients/trongrid/types'; import { BANDWIDTH_METADATA, ENERGY_METADATA, - MAX_ENERGY_METADATA, MAX_BANDWIDTH_METADATA, + MAX_ENERGY_METADATA, Networks, TRX_METADATA, TRX_STAKED_FOR_BANDWIDTH_METADATA, @@ -134,6 +134,9 @@ export class AssetsService { tronAccountInfoRequest.status === 'rejected' || tronAccountResourcesRequest.status === 'rejected' ) { + this.#logger.error( + `Failed to fetch account info or account resources for ${account.address} on network ${scope}`, + ); return [ { symbol: Networks[scope].nativeToken.symbol, @@ -455,25 +458,23 @@ export class AssetsService { tokenTrc20AssetTypes, } = this.#splitAssetsByType(assetTypes); - const [ - nativeTokensMetadata, - stakedTokensMetadata, - energyTokensMetadata, - maximunEnergyTokensMetadata, - bandwidthTokensMetadata, - maximunBandwidthTokensMetadata, - tokensMetadata, - ] = await Promise.all([ - this.#getNativeTokensMetadata(nativeAssetTypes), - this.#getStakedTokensMetadata(stakedNativeAssetTypes), - this.#getEnergyMetadata(energyAssetTypes), - this.#getMaximunEnergyMetadata(maximunEnergyAssetTypes), - this.#getBandwidthMetadata(bandwidthAssetTypes), - this.#getMaximunBandwidthMetadata(maximunBandwidthAssetTypes), - this.#getTokensMetadata([ - ...tokenTrc10AssetTypes, - ...tokenTrc20AssetTypes, - ]), + const nativeTokensMetadata = + this.#getNativeTokensMetadata(nativeAssetTypes); + const stakedTokensMetadata = this.#getStakedTokensMetadata( + stakedNativeAssetTypes, + ); + const energyTokensMetadata = this.#getEnergyMetadata(energyAssetTypes); + const maximunEnergyTokensMetadata = this.#getMaximunEnergyMetadata( + maximunEnergyAssetTypes, + ); + const bandwidthTokensMetadata = + this.#getBandwidthMetadata(bandwidthAssetTypes); + const maximunBandwidthTokensMetadata = this.#getMaximunBandwidthMetadata( + maximunBandwidthAssetTypes, + ); + const tokensMetadata = await this.#getTokensMetadata([ + ...tokenTrc10AssetTypes, + ...tokenTrc20AssetTypes, ]); const result = { diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts index 946f1a37..40b49270 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts @@ -1,18 +1,18 @@ import type { Transaction } from '@metamask/keyring-api'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { + ContractTransactionInfo, + TransactionInfo, +} from '../../clients/trongrid/types'; +import { KnownCaip19Id, Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities'; import contractInfoMock from './mocks/contract-info.json'; import nativeTransferMock from './mocks/native-transfer.json'; import trc10TransferMock from './mocks/trc10-transfer.json'; import trc20TransferMock from './mocks/trc20-transfer.json'; import type { TransactionsRepository } from './TransactionsRepository'; import { TransactionsService } from './TransactionsService'; -import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { - TransactionInfo, - ContractTransactionInfo, -} from '../../clients/trongrid/types'; -import { Network, KnownCaip19Id } from '../../constants'; -import type { TronKeyringAccount } from '../../entities'; import type { ILogger } from '../../utils/logger'; // Import simplified mock data (each file now contains only one transaction) @@ -201,14 +201,24 @@ describe('TransactionsService', () => { mockTrongridApiClient.getTransactionInfoByAddress.mockRejectedValue( apiError, ); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockRejectedValue( + apiError, + ); - await expect( - transactionsService.fetchTransactionsForAccount( - Network.Mainnet, - mockAccount, - ), - ).rejects.toThrow('API request failed'); + const result = await transactionsService.fetchTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); + expect(result).toStrictEqual([]); + expect(mockLogger.error).toHaveBeenCalledWith( + '[🧾 TransactionsService]', + 'Failed to fetch raw transactions', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + '[🧾 TransactionsService]', + 'Failed to fetch TRC20 transactions', + ); expect(true).toBe(true); }); }); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts index 26a1615c..bec1c649 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts @@ -6,6 +6,10 @@ import { groupBy } from 'lodash'; import { TransactionMapper } from './TransactionsMapper'; import type { TransactionsRepository } from './TransactionsRepository'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { + ContractTransactionInfo, + TransactionInfo, +} from '../../clients/trongrid/types'; import type { Network } from '../../constants'; import type { TronKeyringAccount } from '../../entities'; import type { ILogger } from '../../utils/logger'; @@ -43,16 +47,34 @@ export class TransactionsService { * Raw transactions are the primary source containing complete transaction history * TRC20 transactions provide assistance data for enhanced smart contract parsing */ - const [tronRawTransactions, tronTrc20Transactions] = await Promise.all([ - this.#trongridApiClient.getTransactionInfoByAddress( - scope, - account.address, - ), - this.#trongridApiClient.getContractTransactionInfoByAddress( - scope, - account.address, - ), - ]); + let tronRawTransactions: TransactionInfo[] = []; + let tronTrc20Transactions: ContractTransactionInfo[] = []; + + const [tronRawTransactionsRequest, tronTrc20TransactionsRequest] = + await Promise.allSettled([ + this.#trongridApiClient.getTransactionInfoByAddress( + scope, + account.address, + ), + this.#trongridApiClient.getContractTransactionInfoByAddress( + scope, + account.address, + ), + ]); + + if (tronRawTransactionsRequest.status === 'rejected') { + this.#logger.error('Failed to fetch raw transactions'); + tronRawTransactions = []; + } else { + tronRawTransactions = tronRawTransactionsRequest.value; + } + + if (tronTrc20TransactionsRequest.status === 'rejected') { + this.#logger.error('Failed to fetch TRC20 transactions'); + tronTrc20Transactions = []; + } else { + tronTrc20Transactions = tronTrc20TransactionsRequest.value; + } this.#logger.info( `Fetched ${tronRawTransactions.length} raw transactions and ${tronTrc20Transactions.length} TRC20 assistance data for account ${account.address} on network ${scope}.`, From 9f5edabec514b1b63a7fd08a3876265a40a0c708 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:20:59 +0000 Subject: [PATCH 063/238] 1.6.1 (#68) This is the release candidate for version 1.6.1. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 751d157e..e6fbc0cf 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.1] + +### Fixed + +- Use the correct `index` field instead of `groupIndex` for account creation ([#67](https://github.com/MetaMask/snap-tron-wallet/pull/67)) + ## [1.6.0] ### Added @@ -121,7 +127,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.1...HEAD +[1.6.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.0...v1.6.1 [1.6.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.4...v1.6.0 [1.5.4]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.3...v1.5.4 [1.5.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.2...v1.5.3 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 7aad8ed9..f90e4a10 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.6.0", + "version": "1.6.1", "description": "A Tron wallet Snap.", "repository": { "type": "git", From 4e01cec087b6627f0bc4e0f607530d396566c555 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 6 Nov 2025 13:58:50 +0000 Subject: [PATCH 064/238] refactor: add `options` to the unstake method (#69) --- .../tron-wallet-snap/snap.manifest.json | 4 +- .../handlers/clientRequest/clientRequest.ts | 92 +++++++++++++++++-- .../src/handlers/clientRequest/validation.ts | 15 +-- .../src/services/staking/StakingService.ts | 27 +++++- .../tron-wallet-snap/src/utils/errors.ts | 4 + 5 files changed, 126 insertions(+), 16 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 1ef4708e..7908d40c 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.6.0", + "version": "1.6.1", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "m5ryTle3iXLbNSpb6yO0B7O7VPhuDr7aA4oSQjji5Bs=", + "shasum": "86OePfZCMbRecij+oS2AbR6jYPknW9Qwi5ontEbGMc0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index a9c6d158..44b4131c 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -367,18 +367,30 @@ export class ClientRequestHandler { ); const { accountId, assetId, value } = request.params; + console.log( + `[debug] [handleOnStakeAmountInput] All params`, + JSON.stringify({ accountId, assetId, value }), + ); await this.#accountsService.findByIdOrThrow(accountId); const asset = await this.#assetsService.getAssetByAccountId( accountId, assetId, ); + console.log( + `[debug] [handleOnStakeAmountInput] Asset`, + JSON.stringify(asset), + ); /** * If the account doesn't have this asset, treat it as having zero balance */ const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); + console.log( + `[debug] [handleOnStakeAmountInput] Account balance`, + JSON.stringify({ accountBalance, requestBalance }), + ); if (requestBalance.isGreaterThan(accountBalance)) { return { @@ -413,16 +425,29 @@ export class ClientRequestHandler { value, options: { purpose }, } = request.params; + console.log( + `[debug] [handleConfirmStake] All params`, + JSON.stringify({ fromAccountId, assetId, value, purpose }), + ); const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + console.log( + `[debug] [handleConfirmStake] Account`, + JSON.stringify(account), + ); + const asset = await this.#assetsService.getAssetByAccountId( fromAccountId, assetId, ); + console.log(`[debug] [handleConfirmStake] Asset`, JSON.stringify(asset)); const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); - + console.log( + `[debug] [handleConfirmStake] Account balance`, + JSON.stringify({ accountBalance, requestBalance }), + ); /** * Check if account has enough of the asset... */ @@ -442,6 +467,7 @@ export class ClientRequestHandler { amount: requestBalance, purpose, }); + console.log(`[debug] [handleConfirmStake] Stake worked`); return { valid: true, @@ -463,16 +489,39 @@ export class ClientRequestHandler { new InvalidParamsError(), ); - const { accountId, assetId, value } = request.params; + const { + accountId, + assetId, + value, + options: { purpose }, + } = request.params; + console.log( + `[debug] [handleOnUnstakeAmountInput] All params`, + JSON.stringify({ accountId, assetId, value, purpose }), + ); + + /** + * We convert the `slip44:195` to `slip44:195-staked-for-bandwidth` or `slip44:195-staked-for-energy` + * depending on the purpose. + */ + const stakedAssetId = `${assetId}-staked-for-${purpose.toLowerCase()}`; await this.#accountsService.findByIdOrThrow(accountId); const asset = await this.#assetsService.getAssetByAccountId( accountId, - assetId, + stakedAssetId, + ); + console.log( + `[debug] [handleOnUnstakeAmountInput] Asset`, + JSON.stringify(asset), ); const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); + console.log( + `[debug] [handleOnUnstakeAmountInput] Account balance`, + JSON.stringify({ accountBalance, requestBalance }), + ); /** * Check if account has enough of the asset... @@ -504,16 +553,46 @@ export class ClientRequestHandler { new InvalidParamsError(), ); - const { accountId, assetId, value } = request.params; + const { + accountId, + assetId, + value, + options: { purpose }, + } = request.params; + console.log( + `[debug] [handleConfirmUnstake] All params`, + JSON.stringify({ accountId, assetId, value, purpose }), + ); + + /** + * We convert the `slip44:195-staked-for-bandwidth` or `slip44:195-staked-for-energy` to `slip44:195` + * depending on the purpose. + */ + const stakedAssetId = + `${assetId}-staked-for-${purpose.toLowerCase()}` as StakedCaipAssetType; + console.log( + `[debug] [handleConfirmUnstake] Staked asset ID`, + JSON.stringify(stakedAssetId), + ); const account = await this.#accountsService.findByIdOrThrow(accountId); + console.log( + `[debug] [handleConfirmUnstake] Account`, + JSON.stringify(account), + ); + const asset = await this.#assetsService.getAssetByAccountId( accountId, - assetId, + stakedAssetId, ); + console.log(`[debug] [handleConfirmUnstake] Asset`, JSON.stringify(asset)); const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); + console.log( + `[debug] [handleConfirmUnstake] Account balance`, + JSON.stringify({ accountBalance, requestBalance }), + ); /** * Check if account has enough of the asset... @@ -530,9 +609,10 @@ export class ClientRequestHandler { */ await this.#stakingService.unstake({ account, - assetId: assetId as StakedCaipAssetType, + assetId: stakedAssetId, amount: requestBalance, }); + console.log(`[debug] [handleConfirmUnstake] Unstake worked`); return { valid: true, diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index 4fa80167..730b643b 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -9,10 +9,7 @@ import { } from '@metamask/utils'; import { ClientRequestMethod, SendErrorCodes } from './types'; -import { - NativeCaipAssetTypeStruct, - StakedCaipAssetTypeStruct, -} from '../../services/assets/types'; +import { NativeCaipAssetTypeStruct } from '../../services/assets/types'; import { Base64Struct, PositiveNumberStringStruct, @@ -155,7 +152,10 @@ export const OnConfirmStakeRequestStruct = object({ export const OnUnstakeAmountInputRequestParamsStruct = object({ accountId: UuidStruct, - assetId: StakedCaipAssetTypeStruct, + assetId: NativeCaipAssetTypeStruct, + options: object({ + purpose: enums(['ENERGY', 'BANDWIDTH']), + }), value: PositiveNumberStringStruct, }); @@ -168,7 +168,10 @@ export const OnUnstakeAmountInputRequestStruct = object({ export const OnConfirmUnstakeRequestParamsStruct = object({ accountId: UuidStruct, - assetId: StakedCaipAssetTypeStruct, + assetId: NativeCaipAssetTypeStruct, + options: object({ + purpose: enums(['ENERGY', 'BANDWIDTH']), + }), value: PositiveNumberStringStruct, }); diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts index c859c59d..2ef3fb11 100644 --- a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts @@ -52,6 +52,10 @@ export class StakingService { this.#logger.info( `Staking ${amount.toString()} ${assetId} from ${account.address} for ${purpose}...`, ); + console.log( + `[debug] [stake] All params`, + JSON.stringify({ account, assetId, amount, purpose }), + ); const { chainId } = parseCaipAssetType(assetId); @@ -70,8 +74,13 @@ export class StakingService { purpose, account.address, ); + console.log(`[debug] [stake] Transaction`, JSON.stringify(transaction)); + const signedTx = await tronWeb.trx.sign(transaction); - await tronWeb.trx.sendRawTransaction(signedTx); + console.log(`[debug] [stake] Transaction`, JSON.stringify(transaction)); + + const result = await tronWeb.trx.sendRawTransaction(signedTx); + console.log(`[debug] [stake] Result`, JSON.stringify(result)); /** * Sync account after the transaction happens @@ -95,6 +104,10 @@ export class StakingService { this.#logger.info( `Unstaking ${amount.toString()} ${assetId} from ${account.address}...`, ); + console.log( + `[debug] [unstake] All params`, + JSON.stringify({ account, assetId, amount }), + ); const { chainId } = parseCaipAssetType(assetId); @@ -135,6 +148,8 @@ export class StakingService { purpose = 'ENERGY'; } + console.log(`[debug] [unstake] Purpose`, JSON.stringify(purpose)); + if (!purpose) { throw new Error('Invalid asset ID'); } @@ -144,8 +159,16 @@ export class StakingService { purpose, account.address, ); + console.log(`[debug] [unstake] Transaction`, JSON.stringify(transaction)); + const signedTx = await tronWeb.trx.sign(transaction); - await tronWeb.trx.sendRawTransaction(signedTx); + console.log( + `[debug] [unstake] Signed transaction`, + JSON.stringify(signedTx), + ); + + const result = await tronWeb.trx.sendRawTransaction(signedTx); + console.log(`[debug] [unstake] Result`, JSON.stringify(result)); /** * Sync account after the transaction happens diff --git a/merged-packages/tron-wallet-snap/src/utils/errors.ts b/merged-packages/tron-wallet-snap/src/utils/errors.ts index 9546328f..e0f14628 100644 --- a/merged-packages/tron-wallet-snap/src/utils/errors.ts +++ b/merged-packages/tron-wallet-snap/src/utils/errors.ts @@ -64,6 +64,10 @@ export const withCatchAndThrowSnapError = async ( { error }, `[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`, ); + console.log( + `[debug] [withCatchAndThrowSnapError] Error`, + JSON.stringify(error), + ); throw error; } From 36f4bf464014f1091f818a379dcb72dd65792082 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:08:58 +0000 Subject: [PATCH 065/238] 1.7.0 (#70) This is the release candidate for version 1.7.0. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index e6fbc0cf..0b4ae1bb 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.0] + +### Changed + +- Add `options` to the unstake method ([#69](https://github.com/MetaMask/snap-tron-wallet/pull/69)) + ## [1.6.1] ### Fixed @@ -127,7 +133,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.0...HEAD +[1.7.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.1...v1.7.0 [1.6.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.0...v1.6.1 [1.6.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.4...v1.6.0 [1.5.4]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.3...v1.5.4 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index f90e4a10..5772e3e4 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.6.1", + "version": "1.7.0", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 7908d40c..87f29246 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.6.1", + "version": "1.7.0", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "86OePfZCMbRecij+oS2AbR6jYPknW9Qwi5ontEbGMc0=", + "shasum": "XyDM2g1N5IAYT6+0d3tF3umDPQ9PLsSGujNSJzs2CQQ=", "location": { "npm": { "filePath": "dist/bundle.js", From 98313ac58dbf188ee14cfd486eeb05033f4d82ac Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 7 Nov 2025 14:44:33 +0000 Subject: [PATCH 066/238] fix: staking methods need to convert amounts to sun (#71) --- .../src/services/send/SendService.ts | 1 + .../services/staking/StakingService.test.ts | 36 +++++++++++++------ .../src/services/staking/StakingService.ts | 6 ++-- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts index 8aaa8ed8..88bb6679 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -260,6 +260,7 @@ export class SendService { const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); try { + // TODO: We need to fetch the decimals and adjust the amount accordingly const functionSelector = 'transfer(address,uint256)'; const parameter = [ { type: 'address', value: toAddress }, diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts index d49f5981..5b34ba85 100644 --- a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts @@ -132,7 +132,11 @@ describe('StakingService', () => { expect( mockTronWeb.transactionBuilder.freezeBalanceV2, - ).toHaveBeenCalledWith(amount.toNumber(), purpose, mockAccount.address); + ).toHaveBeenCalledWith( + amount.multipliedBy(10 ** 6).toNumber(), + purpose, + mockAccount.address, + ); expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(mockTransaction); expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith( @@ -160,7 +164,11 @@ describe('StakingService', () => { expect( mockTronWeb.transactionBuilder.freezeBalanceV2, - ).toHaveBeenCalledWith(amount.toNumber(), purpose, mockAccount.address); + ).toHaveBeenCalledWith( + amount.multipliedBy(10 ** 6).toNumber(), + purpose, + mockAccount.address, + ); expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( Network.Nile, @@ -198,9 +206,12 @@ describe('StakingService', () => { it('handles different amount values correctly', async () => { const testCases = [ - { amount: BigNumber(1), expectedNumber: 1 }, - { amount: BigNumber(1000000), expectedNumber: 1000000 }, - { amount: BigNumber('1000000000000'), expectedNumber: 1000000000000 }, + { amount: BigNumber(1), expectedNumber: 1000000 }, + { amount: BigNumber(1000000), expectedNumber: 1000000000000 }, + { + amount: BigNumber('1000000000000'), + expectedNumber: 1000000000000000000, + }, ]; for (const testCase of testCases) { @@ -268,7 +279,7 @@ describe('StakingService', () => { expect( mockTronWeb.transactionBuilder.unfreezeBalanceV2, ).toHaveBeenCalledWith( - amount.toNumber(), + amount.multipliedBy(10 ** 6).toNumber(), 'BANDWIDTH', mockAccount.address, ); @@ -321,7 +332,7 @@ describe('StakingService', () => { expect( mockTronWeb.transactionBuilder.unfreezeBalanceV2, - ).toHaveBeenCalledWith(1000000, 'BANDWIDTH', mockAccount.address); + ).toHaveBeenCalledWith(1000000000000, 'BANDWIDTH', mockAccount.address); } }); @@ -361,7 +372,7 @@ describe('StakingService', () => { expect( mockTronWeb.transactionBuilder.unfreezeBalanceV2, - ).toHaveBeenCalledWith(2000000, 'ENERGY', mockAccount.address); + ).toHaveBeenCalledWith(2000000000000, 'ENERGY', mockAccount.address); } }); @@ -399,9 +410,12 @@ describe('StakingService', () => { it('handles different amount values correctly', async () => { const testCases = [ - { amount: BigNumber(1), expectedNumber: 1 }, - { amount: BigNumber(1000000), expectedNumber: 1000000 }, - { amount: BigNumber('1000000000000'), expectedNumber: 1000000000000 }, + { amount: BigNumber(1), expectedNumber: 1000000 }, + { amount: BigNumber(1000000), expectedNumber: 1000000000000 }, + { + amount: BigNumber('1000000000000'), + expectedNumber: 1000000000000000000, + }, ]; for (const testCase of testCases) { diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts index 2ef3fb11..ab04c5da 100644 --- a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts @@ -69,8 +69,9 @@ export class StakingService { privateKeyHex, ); + const amountInSun = amount.multipliedBy(10 ** 6).toNumber(); const transaction = await tronWeb.transactionBuilder.freezeBalanceV2( - amount.toNumber(), + amountInSun, purpose, account.address, ); @@ -154,8 +155,9 @@ export class StakingService { throw new Error('Invalid asset ID'); } + const amountInSun = amount.multipliedBy(10 ** 6).toNumber(); const transaction = await tronWeb.transactionBuilder.unfreezeBalanceV2( - amount.toNumber(), + amountInSun, purpose, account.address, ); From 62be855d9537891fcc4cb8771dfafbe1cc609707 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 7 Nov 2025 16:02:18 +0000 Subject: [PATCH 067/238] All improvements (#73) chore: remove unused Localnet fix: incorrect staked Tron amount due to not counting delegated TRX fix: no TRX on accounts with 0 balance --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions --- merged-packages/tron-wallet-snap/.env.example | 3 - .../tron-wallet-snap/snap.config.ts | 3 - .../tron-wallet-snap/snap.manifest.json | 2 +- .../clients/price-api/PriceApiClient.test.ts | 12 +- .../clients/token-api/TokenApiClient.test.ts | 4 +- .../src/clients/trongrid/types.ts | 3 + .../tron-wallet-snap/src/constants/index.ts | 62 +-------- .../tron-wallet-snap/src/entities/assets.ts | 8 +- .../tron-wallet-snap/src/handlers/keyring.ts | 45 +++++-- .../src/services/accounts/AccountsService.ts | 6 - .../src/services/assets/AssetsService.ts | 125 ++++++++++++------ .../src/services/assets/types.ts | 12 +- .../src/services/config/ConfigProvider.ts | 21 +-- .../services/staking/StakingService.test.ts | 12 -- .../src/services/staking/StakingService.ts | 2 - .../src/utils/getExplorerUrl.ts | 2 - 16 files changed, 144 insertions(+), 178 deletions(-) diff --git a/merged-packages/tron-wallet-snap/.env.example b/merged-packages/tron-wallet-snap/.env.example index 8209edf7..1444ae2f 100644 --- a/merged-packages/tron-wallet-snap/.env.example +++ b/merged-packages/tron-wallet-snap/.env.example @@ -7,7 +7,6 @@ ENVIRONMENT=local RPC_URL_LIST_MAINNET=https://api.trongrid.io RPC_URL_LIST_NILE_TESTNET=https://nile.trongrid.io RPC_URL_LIST_SHASTA_TESTNET=https://api.shasta.trongrid.io/jsonrpc -RPC_URL_LIST_LOCALNET=http://localhost:8899 EXPLORER_MAINNET_BASE_URL=https://tronscan.org EXPLORER_NILE_BASE_URL=https://nile.tronscan.org @@ -25,10 +24,8 @@ LOCAL_API_BASE_URL=http://localhost:8899 TRONGRID_BASE_URL_MAINNET=https://api.trongrid.io TRONGRID_BASE_URL_NILE=https://nile.api.trongrid.io TRONGRID_BASE_URL_SHASTA=https://shasta.api.trongrid.io -TRONGRID_BASE_URL_LOCALNET=https://api.trongrid.io # Tron HTTP API Base URLs TRON_HTTP_BASE_URL_MAINNET=https://api.trongrid.io TRON_HTTP_BASE_URL_NILE=https://nile.trongrid.io TRON_HTTP_BASE_URL_SHASTA=https://shasta.trongrid.io -TRON_HTTP_BASE_URL_LOCALNET=https://api.trongrid.io diff --git a/merged-packages/tron-wallet-snap/snap.config.ts b/merged-packages/tron-wallet-snap/snap.config.ts index 93d2660d..ce3e5691 100644 --- a/merged-packages/tron-wallet-snap/snap.config.ts +++ b/merged-packages/tron-wallet-snap/snap.config.ts @@ -15,7 +15,6 @@ const config: SnapConfig = { RPC_URL_LIST_MAINNET: process.env.RPC_URL_LIST_MAINNET ?? '', RPC_URL_LIST_NILE_TESTNET: process.env.RPC_URL_LIST_NILE_TESTNET ?? '', RPC_URL_LIST_SHASTA_TESTNET: process.env.RPC_URL_LIST_SHASTA_TESTNET ?? '', - RPC_URL_LIST_LOCALNET: process.env.RPC_URL_LIST_LOCALNET ?? '', // Block explorer EXPLORER_MAINNET_BASE_URL: process.env.EXPLORER_MAINNET_BASE_URL ?? '', EXPLORER_NILE_BASE_URL: process.env.EXPLORER_NILE_BASE_URL ?? '', @@ -32,13 +31,11 @@ const config: SnapConfig = { TRONGRID_BASE_URL_MAINNET: process.env.TRONGRID_BASE_URL_MAINNET ?? '', TRONGRID_BASE_URL_NILE: process.env.TRONGRID_BASE_URL_NILE ?? '', TRONGRID_BASE_URL_SHASTA: process.env.TRONGRID_BASE_URL_SHASTA ?? '', - TRONGRID_BASE_URL_LOCALNET: process.env.TRONGRID_BASE_URL_LOCALNET ?? '', TRONGRID_API_KEY: process.env.TRONGRID_API_KEY ?? '', // Tron HTTP API TRON_HTTP_BASE_URL_MAINNET: process.env.TRON_HTTP_BASE_URL_MAINNET ?? '', TRON_HTTP_BASE_URL_NILE: process.env.TRON_HTTP_BASE_URL_NILE ?? '', TRON_HTTP_BASE_URL_SHASTA: process.env.TRON_HTTP_BASE_URL_SHASTA ?? '', - TRON_HTTP_BASE_URL_LOCALNET: process.env.TRON_HTTP_BASE_URL_LOCALNET ?? '', }, polyfills: true, }; diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 87f29246..67ae130e 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "XyDM2g1N5IAYT6+0d3tF3umDPQ9PLsSGujNSJzs2CQQ=", + "shasum": "XeJCRtpLyleIq5NZUCmIs9uxgdFILxOly4yrrabQ0Q4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts index 87ae7803..0f4bdd05 100644 --- a/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts +++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts @@ -141,7 +141,7 @@ describe('PriceApiClient', () => { await expect( client.getMultipleSpotPrices([ - KnownCaip19Id.TrxLocalnet, + KnownCaip19Id.TrxMainnet, KnownCaip19Id.UsdtMainnet, ]), ).rejects.toThrow('Fetch failed'); @@ -159,7 +159,7 @@ describe('PriceApiClient', () => { await expect( client.getMultipleSpotPrices([ - KnownCaip19Id.TrxLocalnet, + KnownCaip19Id.TrxMainnet, KnownCaip19Id.UsdtMainnet, ]), ).rejects.toThrow('HTTP error! status: 404'); @@ -328,7 +328,7 @@ describe('PriceApiClient', () => { it('rejects tokenCaipAssetTypes that are invalid or that include malicious inputs', async () => { await expect( client.getMultipleSpotPrices([ - KnownCaip19Id.TrxLocalnet, + KnownCaip19Id.TrxMainnet, 'INVALID' as CaipAssetType, ]), ).rejects.toThrow( @@ -339,7 +339,7 @@ describe('PriceApiClient', () => { it('rejects vsCurrency parameters that are invalid or that include malicious inputs', async () => { await expect( client.getMultipleSpotPrices( - [KnownCaip19Id.TrxLocalnet], + [KnownCaip19Id.TrxMainnet], 'INVALID' as VsCurrencyParam, ), ).rejects.toThrow(/Expected/u); @@ -351,7 +351,7 @@ describe('PriceApiClient', () => { json: jest.fn().mockResolvedValueOnce({}), }); - await client.getMultipleSpotPrices([KnownCaip19Id.TrxLocalnet]); + await client.getMultipleSpotPrices([KnownCaip19Id.TrxMainnet]); // Verify URL is properly constructed with encoded parameters expect(mockFetch).toHaveBeenCalledWith( @@ -369,7 +369,7 @@ describe('PriceApiClient', () => { await expect( client.getMultipleSpotPrices( - [KnownCaip19Id.TrxLocalnet], + [KnownCaip19Id.TrxMainnet], 'usd\x00\x1F' as VsCurrencyParam, ), ).rejects.toThrow(/Expected/u); diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts index ee36974e..fdc569bd 100644 --- a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts @@ -183,9 +183,9 @@ describe('TokenApiClient', () => { it('returns default metadata if the asset type is not supported by the Token API', async () => { const supportedAssetType = - `${Networks[Network.Localnet].caip2Id}/trc10:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr` as TokenCaipAssetType; + `${Networks[Network.Mainnet].caip2Id}/trc10:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr` as TokenCaipAssetType; const unsupportedAssetType = - `${Networks[Network.Localnet].caip2Id}/trc10:address1` as TokenCaipAssetType; + `${Networks[Network.Mainnet].caip2Id}/trc10:address1` as TokenCaipAssetType; const tokenAddresses = [supportedAssetType, unsupportedAssetType]; mockFetch.mockResolvedValueOnce({ diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts index 15a68667..a1d52bf6 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts @@ -44,6 +44,9 @@ export type RawTronKey = { export type RawTronAccountResource = { energy_window_optimized: boolean; energy_window_size: number; + + delegated_frozenV2_balance_for_energy?: number; + delegated_frozenV2_balance_for_bandwidth?: number; }; export type RawTronFrozenV2 = { diff --git a/merged-packages/tron-wallet-snap/src/constants/index.ts b/merged-packages/tron-wallet-snap/src/constants/index.ts index d8fc5873..3b5fbe42 100644 --- a/merged-packages/tron-wallet-snap/src/constants/index.ts +++ b/merged-packages/tron-wallet-snap/src/constants/index.ts @@ -6,14 +6,12 @@ export enum Network { Mainnet = TrxScope.Mainnet, Nile = TrxScope.Nile, Shasta = TrxScope.Shasta, - Localnet = 'tron:localnet', } export enum KnownCaip19Id { TrxMainnet = `${Network.Mainnet}/slip44:195`, TrxNile = `${Network.Nile}/slip44:195`, TrxShasta = `${Network.Shasta}/slip44:195`, - TrxLocalnet = `${Network.Localnet}/slip44:195`, UsdtMainnet = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, @@ -23,12 +21,10 @@ export enum KnownCaip19Id { TrxStakedForBandwidthMainnet = `${Network.Mainnet}/slip44:195-staked-for-bandwidth`, TrxStakedForBandwidthNile = `${Network.Nile}/slip44:195-staked-for-bandwidth`, TrxStakedForBandwidthShasta = `${Network.Shasta}/slip44:195-staked-for-bandwidth`, - TrxStakedForBandwidthLocalnet = `${Network.Localnet}/slip44:195-staked-for-bandwidth`, TrxStakedForEnergyMainnet = `${Network.Mainnet}/slip44:195-staked-for-energy`, TrxStakedForEnergyNile = `${Network.Nile}/slip44:195-staked-for-energy`, TrxStakedForEnergyShasta = `${Network.Shasta}/slip44:195-staked-for-energy`, - TrxStakedForEnergyLocalnet = `${Network.Localnet}/slip44:195-staked-for-energy`, /** * Tron Resource Assets @@ -36,22 +32,18 @@ export enum KnownCaip19Id { EnergyMainnet = `${Network.Mainnet}/slip44:energy`, EnergyNile = `${Network.Nile}/slip44:energy`, EnergyShasta = `${Network.Shasta}/slip44:energy`, - EnergyLocalnet = `${Network.Localnet}/slip44:energy`, MaximumEnergyMainnet = `${Network.Mainnet}/slip44:maximum-energy`, MaximumEnergyNile = `${Network.Nile}/slip44:maximum-energy`, MaximumEnergyShasta = `${Network.Shasta}/slip44:maximum-energy`, - MaximumEnergyLocalnet = `${Network.Localnet}/slip44:maximum-energy`, BandwidthMainnet = `${Network.Mainnet}/slip44:bandwidth`, BandwidthNile = `${Network.Nile}/slip44:bandwidth`, BandwidthShasta = `${Network.Shasta}/slip44:bandwidth`, - BandwidthLocalnet = `${Network.Localnet}/slip44:bandwidth`, MaximumBandwidthMainnet = `${Network.Mainnet}/slip44:maximum-bandwidth`, MaximumBandwidthNile = `${Network.Nile}/slip44:maximum-bandwidth`, MaximumBandwidthShasta = `${Network.Shasta}/slip44:maximum-bandwidth`, - MaximumBandwidthLocalnet = `${Network.Localnet}/slip44:maximum-bandwidth`, } export const TRX_METADATA = { @@ -130,10 +122,6 @@ export const TokenMetadata = { id: KnownCaip19Id.TrxShasta, ...TRX_METADATA, }, - [KnownCaip19Id.TrxLocalnet]: { - id: KnownCaip19Id.TrxLocalnet, - ...TRX_METADATA, - }, /** * Tron Staked for Bandwidth Metadata */ @@ -149,10 +137,6 @@ export const TokenMetadata = { id: KnownCaip19Id.TrxStakedForBandwidthShasta, ...TRX_STAKED_FOR_BANDWIDTH_METADATA, }, - [KnownCaip19Id.TrxStakedForBandwidthLocalnet]: { - id: KnownCaip19Id.TrxStakedForBandwidthLocalnet, - ...TRX_STAKED_FOR_BANDWIDTH_METADATA, - }, /** * Tron Staked for Energy Metadata */ @@ -168,10 +152,6 @@ export const TokenMetadata = { id: KnownCaip19Id.TrxStakedForEnergyShasta, ...TRX_STAKED_FOR_ENERGY_METADATA, }, - [KnownCaip19Id.TrxStakedForEnergyLocalnet]: { - id: KnownCaip19Id.TrxStakedForEnergyLocalnet, - ...TRX_STAKED_FOR_ENERGY_METADATA, - }, /** * Bandwidth Resource Metadata */ @@ -187,10 +167,6 @@ export const TokenMetadata = { id: KnownCaip19Id.BandwidthShasta, ...BANDWIDTH_METADATA, }, - [KnownCaip19Id.BandwidthLocalnet]: { - id: KnownCaip19Id.BandwidthLocalnet, - ...BANDWIDTH_METADATA, - }, /** * Max Bandwidth Metadata */ @@ -206,10 +182,6 @@ export const TokenMetadata = { id: KnownCaip19Id.MaximumBandwidthShasta, ...MAX_BANDWIDTH_METADATA, }, - [KnownCaip19Id.MaximumBandwidthLocalnet]: { - id: KnownCaip19Id.MaximumBandwidthLocalnet, - ...MAX_BANDWIDTH_METADATA, - }, /** * Energy Resource Metadata */ @@ -225,10 +197,6 @@ export const TokenMetadata = { id: KnownCaip19Id.EnergyShasta, ...ENERGY_METADATA, }, - [KnownCaip19Id.EnergyLocalnet]: { - id: KnownCaip19Id.EnergyLocalnet, - ...ENERGY_METADATA, - }, /** * Max Energy Metadata */ @@ -244,10 +212,6 @@ export const TokenMetadata = { id: KnownCaip19Id.MaximumEnergyShasta, ...MAX_ENERGY_METADATA, }, - [KnownCaip19Id.MaximumEnergyLocalnet]: { - id: KnownCaip19Id.MaximumEnergyLocalnet, - ...MAX_ENERGY_METADATA, - }, } as const; export const Networks = { @@ -289,44 +253,32 @@ export const Networks = { energy: TokenMetadata[KnownCaip19Id.EnergyShasta], maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyShasta], }, - [Network.Localnet]: { - caip2Id: Network.Localnet, - cluster: 'local', - name: 'Tron Localnet', - nativeToken: TokenMetadata[KnownCaip19Id.TrxLocalnet], - stakedForBandwidth: - TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthLocalnet], - stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyLocalnet], - bandwidth: TokenMetadata[KnownCaip19Id.BandwidthLocalnet], - maximumBandwidth: TokenMetadata[KnownCaip19Id.MaximumBandwidthLocalnet], - energy: TokenMetadata[KnownCaip19Id.EnergyLocalnet], - maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyLocalnet], - }, } as const; export const SPECIAL_ASSETS: string[] = [ KnownCaip19Id.TrxStakedForBandwidthMainnet, KnownCaip19Id.TrxStakedForBandwidthNile, KnownCaip19Id.TrxStakedForBandwidthShasta, - KnownCaip19Id.TrxStakedForBandwidthLocalnet, KnownCaip19Id.TrxStakedForEnergyMainnet, KnownCaip19Id.TrxStakedForEnergyNile, KnownCaip19Id.TrxStakedForEnergyShasta, - KnownCaip19Id.TrxStakedForEnergyLocalnet, KnownCaip19Id.BandwidthMainnet, KnownCaip19Id.BandwidthNile, KnownCaip19Id.BandwidthShasta, - KnownCaip19Id.BandwidthLocalnet, KnownCaip19Id.MaximumBandwidthMainnet, KnownCaip19Id.MaximumBandwidthNile, KnownCaip19Id.MaximumBandwidthShasta, - KnownCaip19Id.MaximumBandwidthLocalnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.EnergyNile, KnownCaip19Id.EnergyShasta, - KnownCaip19Id.EnergyLocalnet, KnownCaip19Id.MaximumEnergyMainnet, KnownCaip19Id.MaximumEnergyNile, KnownCaip19Id.MaximumEnergyShasta, - KnownCaip19Id.MaximumEnergyLocalnet, +]; + +export const ESSENTIAL_ASSETS: string[] = [ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxNile, + KnownCaip19Id.TrxShasta, + ...SPECIAL_ASSETS, ]; diff --git a/merged-packages/tron-wallet-snap/src/entities/assets.ts b/merged-packages/tron-wallet-snap/src/entities/assets.ts index fcddbf46..582cddb4 100644 --- a/merged-packages/tron-wallet-snap/src/entities/assets.ts +++ b/merged-packages/tron-wallet-snap/src/entities/assets.ts @@ -28,11 +28,9 @@ export type ResourceAsset = BaseAsset & { | KnownCaip19Id.EnergyMainnet | KnownCaip19Id.EnergyNile | KnownCaip19Id.EnergyShasta - | KnownCaip19Id.EnergyLocalnet | KnownCaip19Id.BandwidthMainnet | KnownCaip19Id.BandwidthNile - | KnownCaip19Id.BandwidthShasta - | KnownCaip19Id.BandwidthLocalnet; + | KnownCaip19Id.BandwidthShasta; }; export type MaximumResourceAsset = BaseAsset & { @@ -40,11 +38,9 @@ export type MaximumResourceAsset = BaseAsset & { | KnownCaip19Id.MaximumEnergyMainnet | KnownCaip19Id.MaximumEnergyNile | KnownCaip19Id.MaximumEnergyShasta - | KnownCaip19Id.MaximumEnergyLocalnet | KnownCaip19Id.MaximumBandwidthMainnet | KnownCaip19Id.MaximumBandwidthNile - | KnownCaip19Id.MaximumBandwidthShasta - | KnownCaip19Id.MaximumBandwidthLocalnet; + | KnownCaip19Id.MaximumBandwidthShasta; }; export type TokenAsset = BaseAsset & { diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts index 66c5c7b4..48eebf21 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -30,7 +30,7 @@ import type { import { sortBy } from 'lodash'; import type { SnapClient } from '../clients/snap/SnapClient'; -import type { Network } from '../constants'; +import { ESSENTIAL_ASSETS, type Network } from '../constants'; import type { TronKeyringAccount } from '../entities'; import { BackgroundEventMethod } from './cronjob'; import type { AccountsService } from '../services/accounts/AccountsService'; @@ -160,8 +160,15 @@ export class KeyringHandler implements Keyring { this.#logger.info('Listing account assets', { accountId }); - const assets = await this.#assetsService.getByKeyringAccountId(accountId); - const result = assets.map((asset) => asset.assetType); + const assetEntities = + await this.#assetsService.getByKeyringAccountId(accountId); + const result = assetEntities + .filter( + (asset) => + ESSENTIAL_ASSETS.includes(asset.assetType) || + Number(asset.rawAmount) > 0, + ) + .map((asset) => asset.assetType); this.#logger.info('Account assets', { accountId, result }); @@ -282,24 +289,34 @@ export class KeyringHandler implements Keyring { try { validateRequest({ accountId, assets }, GetAccountBalancesStruct); + this.#logger.info('Getting account balances', { accountId, assets }); + await this.#getAccountOrThrow(accountId); const assetsList = await this.#assetsService.getByKeyringAccountId(accountId); - const assetsOnlyRequestedAssetTypes = assetsList.filter((asset) => - assets.includes(asset.assetType), + const assetsToUse = assetsList + .filter((asset) => assets.includes(asset.assetType)) + // Remove token assets with zero balance + .filter( + (asset) => + ESSENTIAL_ASSETS.includes(asset.assetType) || + Number(asset.rawAmount) > 0, + ); + + const result = assetsToUse.reduce>( + (acc, asset) => { + acc[asset.assetType] = { + unit: asset.symbol, + amount: asset.uiAmount, + }; + return acc; + }, + {}, ); - const result = assetsOnlyRequestedAssetTypes.reduce< - Record - >((acc, asset) => { - acc[asset.assetType] = { - unit: asset.symbol ?? '', - amount: asset.uiAmount ?? '', - }; - return acc; - }, {}); + this.#logger.info('Account balances', { accountId, result }); validateResponse(result, GetAccounBalancesResponseStruct); return result; diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 97ae5223..5e7bd878 100644 --- a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -213,12 +213,6 @@ export class AccountsService { const keyringAccount = asStrictKeyringAccount(tronKeyringAccount); - /** - * Fetch the account's assets before we send it to the UI so that - * it's loaded with data already in place. - */ - await this.synchronizeAssets([keyringAccount]); - await emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { /** * We can't pass the `keyringAccount` object because it contains the index diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 13de28de..06f14d53 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -31,16 +31,18 @@ import type { AccountResources } from '../../clients/tron-http'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; import type { TronAccount } from '../../clients/trongrid/types'; +import type { KnownCaip19Id, Network } from '../../constants'; import { BANDWIDTH_METADATA, ENERGY_METADATA, + ESSENTIAL_ASSETS, MAX_BANDWIDTH_METADATA, MAX_ENERGY_METADATA, Networks, + TokenMetadata, TRX_METADATA, TRX_STAKED_FOR_BANDWIDTH_METADATA, TRX_STAKED_FOR_ENERGY_METADATA, - type Network, } from '../../constants'; import { configProvider } from '../../context'; import type { AssetEntity } from '../../entities/assets'; @@ -280,6 +282,16 @@ export class AssetsService { } }); + const delegatedBandwidth = + tronAccountInfo?.account_resource + ?.delegated_frozenV2_balance_for_bandwidth ?? 0; + const delegatedEnergy = + tronAccountInfo?.account_resource + ?.delegated_frozenV2_balance_for_energy ?? 0; + + stakedBandwidthAmount += delegatedBandwidth; + stakedEnergyAmount += delegatedEnergy; + if (stakedBandwidthAmount > 0) { const stakedBandwidthAsset: AssetEntity = { assetType: Networks[scope].stakedForBandwidth.id, @@ -759,8 +771,16 @@ export class AssetsService { async saveMany(assets: AssetEntity[]): Promise { this.#logger.info('Saving assets', assets); + /** + * Should we save the assets incrementally? + * - If true, only saves and emits events for the assets that have changed (new or balance changed). Better performance because it only informs the client of what has changed. + * - If false, saves all assets. More reliable because it enforces that the client has the same state of assets as the snap. + */ + const isIncremental = false; + const hasZeroAmount = (asset: AssetEntity): boolean => asset.rawAmount === '0' || asset.uiAmount === '0'; + const hasNonZeroAmount = (asset: AssetEntity): boolean => !hasZeroAmount(asset); @@ -777,21 +797,28 @@ export class AssetsService { item.assetType === asset.assetType, ); - const wasSavedWithZeroAmount = ( - asset: AssetEntity, - ): boolean | undefined => { + const wasSavedWithZeroAmount = (asset: AssetEntity): boolean => { const savedAsset = savedAssets.find( (item) => item.keyringAccountId === asset.keyringAccountId && item.assetType === asset.assetType, ); - return savedAsset && hasZeroAmount(savedAsset); + return Boolean(savedAsset && hasZeroAmount(savedAsset)); }; const isNativeAsset = (asset: AssetEntity): boolean => asset.assetType.includes('/slip44:195'); + const shouldBeInRemovedList = (asset: AssetEntity): boolean => + hasZeroAmount(asset) && !isNativeAsset(asset); // Never remove native assets from the account asset list + + const shouldBeInAddedList = (asset: AssetEntity): boolean => + !shouldBeInRemovedList(asset) && + (!isIncremental || + ((isNew(asset) || wasSavedWithZeroAmount(asset)) && + hasNonZeroAmount(asset))); + const assetListUpdatedPayload = assets.reduce< AccountAssetListUpdatedEvent['params']['assets'] >( @@ -800,16 +827,11 @@ export class AssetsService { [asset.keyringAccountId]: { added: [ ...(acc[asset.keyringAccountId]?.added ?? []), - ...((isNew(asset) || wasSavedWithZeroAmount(asset)) && - hasNonZeroAmount(asset) - ? [asset.assetType] - : []), + ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), ], removed: [ ...(acc[asset.keyringAccountId]?.removed ?? []), - ...(hasZeroAmount(asset) && !isNativeAsset(asset) // Never remove native assets from the account asset list - ? [asset.assetType] - : []), + ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), ], }, }), @@ -830,37 +852,11 @@ export class AssetsService { } // Notify the extension about the changed balances in a single event - const hasChanged = (asset: AssetEntity): boolean => AssetsService.hasChanged(asset, savedAssets); - /** - * Build the event payload for snap keyring event `AccountBalancesUpdated`. - * - * @example - * { - * "balances": { - * "keyringAccountId0": { - * "assetType00": { - * "unit": "XYZ", - * "amount": "1234" - * }, - * "assetType01": { - * "unit": "ABC", - * "amount": "5678" - * } - * }, - * "keyringAccountId1": { - * "assetType10": { - * "unit": "XYZ", - * "amount": "42" - * } - * } - * } - * } - */ const balancesUpdatedPayload = assets - .filter(hasChanged) + .filter(isIncremental ? hasChanged : (): boolean => true) .reduce( (acc, asset) => ({ ...acc, @@ -896,10 +892,59 @@ export class AssetsService { return Object.values(assetsByAccount).flat(); } + /** + * Creates an asset entity with zero balance from a known CAIP-19 asset ID. + * Uses pre-calculated metadata from TokenMetadata. + * + * @param assetId - The CAIP-19 asset ID (e.g., KnownCaip19Id.TrxMainnet). + * @param keyringAccountId - The keyring account ID. + * @returns The asset entity with zero balance. + */ + #createZeroBalanceAsset( + assetId: KnownCaip19Id, + keyringAccountId: string, + ): AssetEntity { + const metadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; + const { chainId } = parseCaipAssetType(assetId); + + return { + assetType: metadata.id, + keyringAccountId, + network: chainId as Network, + symbol: metadata.symbol, + decimals: metadata.decimals, + rawAmount: '0', + uiAmount: '0', + } as AssetEntity; + } + async getByKeyringAccountId( keyringAccountId: string, ): Promise { - return this.#assetsRepository.getByAccountId(keyringAccountId); + 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 missingEssentialAssets: AssetEntity[] = []; + + for (const essentialAssetId of ESSENTIAL_ASSETS) { + const savedAsset = savedAssets.find( + (asset) => (asset.assetType as string) === essentialAssetId, + ); + + if (!savedAsset) { + const zeroBalanceAsset = this.#createZeroBalanceAsset( + essentialAssetId as KnownCaip19Id, + keyringAccountId, + ); + missingEssentialAssets.push(zeroBalanceAsset); + } + } + + return [...savedAssets, ...missingEssentialAssets]; } /** diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts index e1266318..7adca554 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -16,7 +16,7 @@ export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; */ export const NativeCaipAssetTypeStruct = pattern( CaipAssetTypeStruct, - /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:195$/u, + /^tron:(728126428|3448148188|2494104990)\/slip44:195$/u, ); /** @@ -24,7 +24,7 @@ export const NativeCaipAssetTypeStruct = pattern( */ export const StakedCaipAssetTypeStruct = pattern( CaipAssetTypeStruct, - /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:195-staked-for-(energy|bandwidth)$/u, + /^tron:(728126428|3448148188|2494104990)\/slip44:195-staked-for-(energy|bandwidth)$/u, ); /** @@ -32,7 +32,7 @@ export const StakedCaipAssetTypeStruct = pattern( */ export const ResourceCaipAssetTypeStruct = pattern( CaipAssetTypeStruct, - /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:(energy|bandwidth)$/u, + /^tron:(728126428|3448148188|2494104990)\/slip44:(energy|bandwidth)$/u, ); /** @@ -40,7 +40,7 @@ export const ResourceCaipAssetTypeStruct = pattern( */ export const MaximumResourceCaipAssetTypeStruct = pattern( CaipAssetTypeStruct, - /^tron:(728126428|3448148188|2494104990|localnet)\/slip44:maximum-(energy|bandwidth)$/u, + /^tron:(728126428|3448148188|2494104990)\/slip44:maximum-(energy|bandwidth)$/u, ); /** @@ -48,7 +48,7 @@ export const MaximumResourceCaipAssetTypeStruct = pattern( */ export const TokenCaipAssetTypeStruct = pattern( CaipAssetTypeStruct, - /^tron:(728126428|3448148188|2494104990|localnet)\/(trc10|trc20):[a-zA-Z0-9]+$/u, + /^tron:(728126428|3448148188|2494104990)\/(trc10|trc20):[a-zA-Z0-9]+$/u, ); /** @@ -56,5 +56,5 @@ export const TokenCaipAssetTypeStruct = pattern( */ export const NftCaipAssetTypeStruct = pattern( CaipAssetTypeStruct, - /^tron:(728126428|3448148188|2494104990|localnet)\/trc721:[a-zA-Z0-9]+$/u, + /^tron:(728126428|3448148188|2494104990)\/trc721:[a-zA-Z0-9]+$/u, ); diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index bbf27247..805ab3b7 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -16,7 +16,7 @@ import { UrlStruct } from '../../validation/structs'; const ENVIRONMENT_TO_ACTIVE_NETWORKS = { production: [Network.Mainnet], local: [Network.Mainnet], - test: [Network.Localnet], + test: [Network.Mainnet], }; const CommaSeparatedListOfUrlsStruct = coerce( @@ -25,18 +25,11 @@ const CommaSeparatedListOfUrlsStruct = coerce( (value: string) => value.split(','), ); -const CommaSeparatedListOfStringsStruct = coerce( - array(string()), - string(), - (value: string) => value.split(','), -); - const EnvStruct = object({ ENVIRONMENT: enums(['local', 'test', 'production']), RPC_URL_LIST_MAINNET: CommaSeparatedListOfUrlsStruct, RPC_URL_LIST_NILE_TESTNET: CommaSeparatedListOfUrlsStruct, RPC_URL_LIST_SHASTA_TESTNET: CommaSeparatedListOfUrlsStruct, - RPC_URL_LIST_LOCALNET: CommaSeparatedListOfStringsStruct, EXPLORER_MAINNET_BASE_URL: UrlStruct, EXPLORER_NILE_BASE_URL: UrlStruct, EXPLORER_SHASTA_BASE_URL: UrlStruct, @@ -50,11 +43,9 @@ const EnvStruct = object({ TRONGRID_BASE_URL_MAINNET: UrlStruct, TRONGRID_BASE_URL_NILE: UrlStruct, TRONGRID_BASE_URL_SHASTA: UrlStruct, - TRONGRID_BASE_URL_LOCALNET: UrlStruct, TRON_HTTP_BASE_URL_MAINNET: UrlStruct, TRON_HTTP_BASE_URL_NILE: UrlStruct, TRON_HTTP_BASE_URL_SHASTA: UrlStruct, - TRON_HTTP_BASE_URL_LOCALNET: UrlStruct, }); export type Env = Infer; @@ -131,7 +122,6 @@ export class ConfigProvider { RPC_URL_LIST_MAINNET: process.env.RPC_URL_LIST_MAINNET, RPC_URL_LIST_NILE_TESTNET: process.env.RPC_URL_LIST_NILE_TESTNET, RPC_URL_LIST_SHASTA_TESTNET: process.env.RPC_URL_LIST_SHASTA_TESTNET, - RPC_URL_LIST_LOCALNET: process.env.RPC_URL_LIST_LOCALNET, // Block explorer EXPLORER_MAINNET_BASE_URL: process.env.EXPLORER_MAINNET_BASE_URL, EXPLORER_NILE_BASE_URL: process.env.EXPLORER_NILE_BASE_URL, @@ -147,13 +137,11 @@ export class ConfigProvider { TRONGRID_BASE_URL_MAINNET: process.env.TRONGRID_BASE_URL_MAINNET, TRONGRID_BASE_URL_NILE: process.env.TRONGRID_BASE_URL_NILE, TRONGRID_BASE_URL_SHASTA: process.env.TRONGRID_BASE_URL_SHASTA, - TRONGRID_BASE_URL_LOCALNET: process.env.TRONGRID_BASE_URL_LOCALNET, TRONGRID_API_KEY: process.env.TRONGRID_API_KEY, // // Tron HTTP API URLs TRON_HTTP_BASE_URL_MAINNET: process.env.TRON_HTTP_BASE_URL_MAINNET, TRON_HTTP_BASE_URL_NILE: process.env.TRON_HTTP_BASE_URL_NILE, TRON_HTTP_BASE_URL_SHASTA: process.env.TRON_HTTP_BASE_URL_SHASTA, - TRON_HTTP_BASE_URL_LOCALNET: process.env.TRON_HTTP_BASE_URL_LOCALNET, }; // Validate and parse them before returning @@ -179,11 +167,6 @@ export class ConfigProvider { rpcUrls: environment.RPC_URL_LIST_SHASTA_TESTNET, explorerBaseUrl: environment.EXPLORER_SHASTA_BASE_URL, }, - { - ...Networks[Network.Localnet], - rpcUrls: environment.RPC_URL_LIST_LOCALNET, - explorerBaseUrl: environment.EXPLORER_MAINNET_BASE_URL, - }, ], activeNetworks: ENVIRONMENT_TO_ACTIVE_NETWORKS[environment.ENVIRONMENT], priceApi: { @@ -233,7 +216,6 @@ export class ConfigProvider { [Network.Mainnet]: environment.TRONGRID_BASE_URL_MAINNET, [Network.Nile]: environment.TRONGRID_BASE_URL_NILE, [Network.Shasta]: environment.TRONGRID_BASE_URL_SHASTA, - [Network.Localnet]: environment.TRONGRID_BASE_URL_LOCALNET, }, }, tronHttpApi: { @@ -241,7 +223,6 @@ export class ConfigProvider { [Network.Mainnet]: environment.TRON_HTTP_BASE_URL_MAINNET, [Network.Nile]: environment.TRON_HTTP_BASE_URL_NILE, [Network.Shasta]: environment.TRON_HTTP_BASE_URL_SHASTA, - [Network.Localnet]: environment.TRON_HTTP_BASE_URL_LOCALNET, }, }, }; diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts index 5b34ba85..6af961a3 100644 --- a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts @@ -181,10 +181,6 @@ describe('StakingService', () => { { assetId: KnownCaip19Id.TrxMainnet, expectedNetwork: Network.Mainnet }, { assetId: KnownCaip19Id.TrxNile, expectedNetwork: Network.Nile }, { assetId: KnownCaip19Id.TrxShasta, expectedNetwork: Network.Shasta }, - { - assetId: KnownCaip19Id.TrxLocalnet, - expectedNetwork: Network.Localnet, - }, ]; for (const testCase of testCases) { @@ -310,10 +306,6 @@ describe('StakingService', () => { assetId: KnownCaip19Id.TrxStakedForBandwidthShasta, expectedNetwork: Network.Shasta, }, - { - assetId: KnownCaip19Id.TrxStakedForBandwidthLocalnet, - expectedNetwork: Network.Localnet, - }, ]; for (const testCase of testCases) { @@ -350,10 +342,6 @@ describe('StakingService', () => { assetId: KnownCaip19Id.TrxStakedForEnergyShasta, expectedNetwork: Network.Shasta, }, - { - assetId: KnownCaip19Id.TrxStakedForEnergyLocalnet, - expectedNetwork: Network.Localnet, - }, ]; for (const testCase of testCases) { diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts index ab04c5da..165b098a 100644 --- a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts @@ -132,7 +132,6 @@ export class StakingService { KnownCaip19Id.TrxStakedForBandwidthMainnet, KnownCaip19Id.TrxStakedForBandwidthNile, KnownCaip19Id.TrxStakedForBandwidthShasta, - KnownCaip19Id.TrxStakedForBandwidthLocalnet, ].includes(assetId as KnownCaip19Id) ) { purpose = 'BANDWIDTH'; @@ -143,7 +142,6 @@ export class StakingService { KnownCaip19Id.TrxStakedForEnergyMainnet, KnownCaip19Id.TrxStakedForEnergyNile, KnownCaip19Id.TrxStakedForEnergyShasta, - KnownCaip19Id.TrxStakedForEnergyLocalnet, ].includes(assetId as KnownCaip19Id) ) { purpose = 'ENERGY'; diff --git a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts index 3c88fcc5..ef7a211c 100644 --- a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts +++ b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts @@ -22,8 +22,6 @@ export function getExplorerUrl( [Network.Nile]: process.env.EXPLORER_NILE_BASE_URL as string, /* eslint-disable-next-line no-restricted-globals */ [Network.Shasta]: process.env.EXPLORER_SHASTA_BASE_URL as string, - /* eslint-disable-next-line no-restricted-globals */ - [Network.Localnet]: process.env.EXPLORER_LOCALNET_BASE_URL as string, }; const baseUrl = NETWORK_TO_EXPLORER_PATH[scope]; From ce19630f84990e5ec76eb5539a93f49337be2ecb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 09:59:57 +0000 Subject: [PATCH 068/238] 1.7.1 (#74) This is the release candidate for version 1.7.1. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 10 +++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index 0b4ae1bb..ddcbde0f 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.1] + +### Uncategorized + +- All improvements ([#73](https://github.com/MetaMask/snap-tron-wallet/pull/73)) +- fix: staking methods need to convert amounts to sun ([#71](https://github.com/MetaMask/snap-tron-wallet/pull/71)) + ## [1.7.0] ### Changed @@ -133,7 +140,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.1...HEAD +[1.7.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.0...v1.7.1 [1.7.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.1...v1.7.0 [1.6.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.0...v1.6.1 [1.6.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.5.4...v1.6.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 5772e3e4..460af50a 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.7.0", + "version": "1.7.1", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 67ae130e..1cb16db4 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.7.0", + "version": "1.7.1", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "XeJCRtpLyleIq5NZUCmIs9uxgdFILxOly4yrrabQ0Q4=", + "shasum": "vu3K/3Rg3zSIx8JPAzM5kj3MvjqFAssGYEzPGs5/Xng=", "location": { "npm": { "filePath": "dist/bundle.js", From 57864e96cd235c56a2ee6b93e666c1fef5f2add4 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Nov 2025 12:28:17 +0000 Subject: [PATCH 069/238] fix: adjust decimals when sending TRC20 tokens (#76) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../handlers/clientRequest/clientRequest.ts | 14 ++++++++++++- .../src/services/send/SendService.ts | 20 +++++++++++-------- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 1cb16db4..262de1d6 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "vu3K/3Rg3zSIx8JPAzM5kj3MvjqFAssGYEzPGs5/Xng=", + "shasum": "qpbhzEZoVoRPkGqpe7cs55GhR4DJjlLWKDP4pXmm+m8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 44b4131c..93ac2e76 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -288,11 +288,23 @@ export class ClientRequestHandler { const { fromAccountId, toAddress, amount, assetId } = request.params; + const asset = await this.#assetsService.getAssetByAccountId( + fromAccountId, + assetId, + ); + + if (!asset) { + return { + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }; + } + const transaction = await this.#sendService.sendAsset({ fromAccountId, toAddress, + asset, amount: BigNumber(amount).toNumber(), - assetId, }); return { diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts index 88bb6679..a340625a 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -4,6 +4,7 @@ import type { TransactionResult } from './types'; import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import type { Network } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; import { BackgroundEventMethod } from '../../handlers/cronjob'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; import type { AccountsService } from '../accounts/AccountsService'; @@ -37,16 +38,16 @@ export class SendService { async sendAsset({ fromAccountId, toAddress, + asset, amount, - assetId, }: { fromAccountId: string; toAddress: string; + asset: AssetEntity; amount: number; - assetId: string; }): Promise { const { chainId, assetNamespace, assetReference } = parseCaipAssetType( - assetId as `${string}:${string}/${string}:${string}`, + asset.assetType, ); try { @@ -76,8 +77,9 @@ export class SendService { scope: chainId as Network, fromAccountId, toAddress, - amount, contractAddress: assetReference, + amount, + decimals: asset.decimals, }); default: @@ -241,14 +243,16 @@ export class SendService { scope, fromAccountId, toAddress, - amount, contractAddress, + amount, + decimals, }: { scope: Network; fromAccountId: string; toAddress: string; - amount: number; contractAddress: string; + amount: number; + decimals: number; }): Promise { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); @@ -260,11 +264,11 @@ export class SendService { const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); try { - // TODO: We need to fetch the decimals and adjust the amount accordingly const functionSelector = 'transfer(address,uint256)'; + const decimalsAdjustedAmount = amount * 10 ** decimals; const parameter = [ { type: 'address', value: toAddress }, - { type: 'uint256', value: amount }, + { type: 'uint256', value: decimalsAdjustedAmount }, ]; const contractResult = From 24e568a2ead62ca017971867df0fe0e1f473cb08 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Mon, 10 Nov 2025 15:05:08 +0100 Subject: [PATCH 070/238] feat: tron using infura (#75) * Adds the infura endpoint * Removes the need off `TRONGRID_API_KEY` --- merged-packages/tron-wallet-snap/snap.config.ts | 1 - merged-packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/tron-http/TronHttpClient.ts | 8 -------- .../src/clients/trongrid/TrongridApiClient.ts | 9 +-------- .../src/clients/tronweb/TronWebFactory.ts | 5 +---- .../src/services/config/ConfigProvider.ts | 4 ---- 6 files changed, 3 insertions(+), 26 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.config.ts b/merged-packages/tron-wallet-snap/snap.config.ts index ce3e5691..ed7d1ef2 100644 --- a/merged-packages/tron-wallet-snap/snap.config.ts +++ b/merged-packages/tron-wallet-snap/snap.config.ts @@ -31,7 +31,6 @@ const config: SnapConfig = { TRONGRID_BASE_URL_MAINNET: process.env.TRONGRID_BASE_URL_MAINNET ?? '', TRONGRID_BASE_URL_NILE: process.env.TRONGRID_BASE_URL_NILE ?? '', TRONGRID_BASE_URL_SHASTA: process.env.TRONGRID_BASE_URL_SHASTA ?? '', - TRONGRID_API_KEY: process.env.TRONGRID_API_KEY ?? '', // Tron HTTP API TRON_HTTP_BASE_URL_MAINNET: process.env.TRON_HTTP_BASE_URL_MAINNET ?? '', TRON_HTTP_BASE_URL_NILE: process.env.TRON_HTTP_BASE_URL_NILE ?? '', diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 262de1d6..8eb59b92 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "qpbhzEZoVoRPkGqpe7cs55GhR4DJjlLWKDP4pXmm+m8=", + "shasum": "rE3bQt3RZZYn07fBOLoQA/rgisZL+nvvZOdLr2R6adw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts index 8de6fa19..69bb5fa1 100644 --- a/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts @@ -16,8 +16,6 @@ import type { ConfigProvider } from '../../services/config'; * Handles contract interactions, constant contract calls, etc. */ export class TronHttpClient { - readonly #apiKey?: string; - readonly #clients: Map< Network, { @@ -27,9 +25,7 @@ export class TronHttpClient { > = new Map(); constructor({ configProvider }: { configProvider: ConfigProvider }) { - const { apiKey } = configProvider.get().trongridApi; const { baseUrls } = configProvider.get().tronHttpApi; - this.#apiKey = apiKey; // Initialize clients for all networks Object.entries(baseUrls).forEach(([network, baseUrl]) => { @@ -39,10 +35,6 @@ export class TronHttpClient { 'Access-Control-Allow-Origin': '*', }; - if (this.#apiKey) { - headers['TRON-PRO-API-KEY'] = this.#apiKey; - } - this.#clients.set(network as Network, { baseUrl, headers }); }); } diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts index 40ede14b..66a33109 100644 --- a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts @@ -9,8 +9,6 @@ import type { Network } from '../../constants'; import type { ConfigProvider } from '../../services/config'; export class TrongridApiClient { - readonly #apiKey?: string; - readonly #clients: Map< Network, { @@ -20,8 +18,7 @@ export class TrongridApiClient { > = new Map(); constructor({ configProvider }: { configProvider: ConfigProvider }) { - const { apiKey, baseUrls } = configProvider.get().trongridApi; - this.#apiKey = apiKey; + const { baseUrls } = configProvider.get().trongridApi; // Initialize clients for all networks Object.entries(baseUrls).forEach(([network, baseUrl]) => { @@ -31,10 +28,6 @@ export class TrongridApiClient { 'Access-Control-Allow-Origin': '*', }; - if (this.#apiKey) { - headers['TRON-PRO-API-KEY'] = this.#apiKey; - } - this.#clients.set(network as Network, { baseUrl, headers }); }); } diff --git a/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts index 4b389be6..01653e33 100644 --- a/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts +++ b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts @@ -30,18 +30,15 @@ export class TronWebFactory { */ createClient(network: Network, privateKey?: string): TronWeb { const config = this.#configProvider.get(); - const { apiKey, baseUrls } = config.trongridApi; + const { baseUrls } = config.trongridApi; const fullHost = baseUrls[network]; if (!fullHost) { throw new Error(`No configuration found for network: ${network}`); } - const headers = apiKey ? { 'TRON-PRO-API-KEY': apiKey } : {}; - const tronWebConfig = { fullHost, - headers, ...(privateKey && { privateKey }), }; diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts index 805ab3b7..33c6d6bf 100644 --- a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -39,7 +39,6 @@ const EnvStruct = object({ SECURITY_ALERTS_API_BASE_URL: UrlStruct, NFT_API_BASE_URL: UrlStruct, LOCAL_API_BASE_URL: string(), - TRONGRID_API_KEY: string(), TRONGRID_BASE_URL_MAINNET: UrlStruct, TRONGRID_BASE_URL_NILE: UrlStruct, TRONGRID_BASE_URL_SHASTA: UrlStruct, @@ -89,7 +88,6 @@ export type Config = { }; }; trongridApi: { - apiKey: string; baseUrls: Record; }; tronHttpApi: { @@ -137,7 +135,6 @@ export class ConfigProvider { TRONGRID_BASE_URL_MAINNET: process.env.TRONGRID_BASE_URL_MAINNET, TRONGRID_BASE_URL_NILE: process.env.TRONGRID_BASE_URL_NILE, TRONGRID_BASE_URL_SHASTA: process.env.TRONGRID_BASE_URL_SHASTA, - TRONGRID_API_KEY: process.env.TRONGRID_API_KEY, // // Tron HTTP API URLs TRON_HTTP_BASE_URL_MAINNET: process.env.TRON_HTTP_BASE_URL_MAINNET, TRON_HTTP_BASE_URL_NILE: process.env.TRON_HTTP_BASE_URL_NILE, @@ -211,7 +208,6 @@ export class ConfigProvider { }, }, trongridApi: { - apiKey: environment.TRONGRID_API_KEY, baseUrls: { [Network.Mainnet]: environment.TRONGRID_BASE_URL_MAINNET, [Network.Nile]: environment.TRONGRID_BASE_URL_NILE, From d21f4aab7ab4fc87bb7f5a3de3fa23a076d0d573 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Nov 2025 16:47:05 +0000 Subject: [PATCH 071/238] More fixes (#77) fix: return transaction history fees in TRX not in SUN --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../handlers/clientRequest/clientRequest.ts | 49 ++++-- .../src/handlers/clientRequest/validation.ts | 4 + .../src/services/assets/AssetsRepository.ts | 26 +++ .../src/services/assets/AssetsService.ts | 12 +- .../send/FeeCalculatorService.test.ts | 151 +++++++++--------- .../src/services/send/FeeCalculatorService.ts | 61 ++++--- .../transactions/TransactionsMapper.test.ts | 12 +- .../transactions/TransactionsMapper.ts | 3 +- 9 files changed, 200 insertions(+), 120 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 8eb59b92..468fd4bc 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "rE3bQt3RZZYn07fBOLoQA/rgisZL+nvvZOdLr2R6adw=", + "shasum": "a4EolVm0o2wBkluEZ6/bvpBB0d6waiwNIAPiY7C/b98=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 93ac2e76..a3c6a780 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -157,7 +157,6 @@ export class ClientRequestHandler { entropySource: account.entropySource, derivationPath: account.derivationPath, }); - const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); /** @@ -325,22 +324,50 @@ export class ClientRequestHandler { assertOrThrow(request, ComputeFeeRequestStruct, new InvalidParamsError()); const { - params: { scope, transaction, accountId }, + params: { + scope, + transaction: transactionBase64, + accountId, + options: { visible, type }, + }, } = request; - await this.#accountsService.findByIdOrThrow(accountId); + /** + * Start by recreating the transaction object with the missing fields + * just like we do for `signAndSendTransaction` + */ + const account = await this.#accountsService.findByIdOrThrow(accountId); + + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + // eslint-disable-next-line no-restricted-globals + const rawDataHex = Buffer.from(transactionBase64, 'base64').toString('hex'); + const rawData = tronWeb.utils.transaction.DeserializeTransaction( + type, + rawDataHex, + ); + const txID = sha256(`0x${rawDataHex}`).slice(2); + const transaction = { + visible, + txID, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: rawData, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: rawDataHex, + }; /** * Get available Energy and Bandwidth from account assets. */ - const energyAsset = await this.#assetsService.getAssetByAccountId( - accountId, - Networks[scope].energy.id, - ); - const bandwidthAsset = await this.#assetsService.getAssetByAccountId( - accountId, - Networks[scope].bandwidth.id, - ); + const [bandwidthAsset, energyAsset] = + await this.#assetsService.getAssetsByAccountId(accountId, [ + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); const availableEnergy = energyAsset ? BigNumber(energyAsset.rawAmount) diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts index 730b643b..c45c8713 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -103,6 +103,10 @@ export const ComputeFeeRequestParamsStruct = object({ transaction: Base64Struct, accountId: UuidStruct, scope: ScopeStringStruct, + options: object({ + visible: boolean(), + type: string(), + }), }); export const ComputeFeeRequestStruct = object({ diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts index f3e7d1fe..e540b000 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts @@ -28,6 +28,32 @@ export class AssetsRepository { return assets.find((asset) => asset.assetType === assetType) ?? null; } + /** + * Get assets by account ID and asset types. + * + * @param keyringAccountId - The keyring account ID. + * @param assetTypes - The asset types to filter by. + * @returns An array of assets matching the criteria. + */ + async getByAccountIdAndAssetTypes( + keyringAccountId: string, + assetTypes: string[], + ): Promise<(AssetEntity | null)[]> { + const assets = await this.getByAccountId(keyringAccountId); + const result: (AssetEntity | null)[] = []; + + // We iterate through the assetTypes to preserve the order + for (const assetType of assetTypes) { + const asset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (currAsset) => currAsset.assetType === assetType, + ); + result.push(asset ?? null); + } + + return result; + } + async getAll(): Promise { const assetsByAccount = (await this.#state.getKey('assets')) ?? diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 06f14d53..70986a61 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -103,10 +103,20 @@ export class AssetsService { return caipAssetId.includes('swift:0/iso4217:'); } - async getAssetsByAccountId(accountId: string): Promise { + async getAllAssetsByAccountId(accountId: string): Promise { return this.#assetsRepository.getByAccountId(accountId); } + async getAssetsByAccountId( + accountId: string, + assetTypes: string[], + ): Promise<(AssetEntity | null)[]> { + return this.#assetsRepository.getByAccountIdAndAssetTypes( + accountId, + assetTypes, + ); + } + async getAssetByAccountId( accountId: string, assetType: string, diff --git a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts index 8ff88a27..bf1fd493 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts @@ -5,6 +5,9 @@ import { BigNumber } from 'bignumber.js'; import { FeeCalculatorService } from './FeeCalculatorService'; import { Network } from '../../constants'; import { mockLogger } from '../../utils/mockLogger'; +import nativeTransferMock from '../transactions/mocks/native-transfer.json'; +import trc10TransferMock from '../transactions/mocks/trc10-transfer.json'; +import trc20TransferMock from '../transactions/mocks/trc20-transfer.json'; const mockTronWebFactory = { createClient: jest.fn(), @@ -14,21 +17,57 @@ const mockTrongridApiClient = { getChainParameters: jest.fn(), } as any; -const createBase64Transaction = (contractType: string, data: any = {}) => { - const transaction = { +// Helper to get transaction examples in the expected format +const getTransactionExample = (type: 'native' | 'trc10' | 'trc20'): any => { + let mockData; + switch (type) { + case 'native': + mockData = nativeTransferMock; + break; + case 'trc10': + mockData = trc10TransferMock; + break; + case 'trc20': + mockData = trc20TransferMock; + break; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unknown transaction type: ${type}`); + } + + // Extract the transaction structure that matches the expected Transaction type + return { + visible: false, + txID: mockData.txID, + raw_data_hex: mockData.raw_data_hex, + raw_data: mockData.raw_data, + }; +}; + +// Helper to create a large transaction by modifying the TRC20 example +const createLargeTransaction = (): any => { + const baseTransaction = getTransactionExample('trc20'); + // Modify the data field to be much larger to simulate bandwidth issues + const largeData = 'b'.repeat(2000); + + return { + ...baseTransaction, raw_data: { + ...baseTransaction.raw_data, contract: [ { - type: contractType, + ...baseTransaction.raw_data.contract[0], parameter: { - value: data, + ...baseTransaction.raw_data.contract[0].parameter, + value: { + ...baseTransaction.raw_data.contract[0].parameter.value, + data: largeData, + }, }, }, ], }, }; - // eslint-disable-next-line no-restricted-globals - return Buffer.from(JSON.stringify(transaction)).toString('base64'); }; describe('FeeCalculatorService', () => { @@ -70,7 +109,7 @@ describe('FeeCalculatorService', () => { describe('TransferContract scenarios (no energy needed)', () => { it('has enough bandwidth', async () => { - const transaction = createBase64Transaction('TransferContract'); + const transaction = getTransactionExample('native'); const availableEnergy = BigNumber(0); const availableBandwidth = BigNumber(1000000); // More than needed @@ -88,7 +127,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'BANDWIDTH', type: 'tron:728126428/slip44:bandwidth', - amount: '80000', + amount: '758000', fungible: true, }, }, @@ -96,7 +135,7 @@ describe('FeeCalculatorService', () => { }); it('not enough bandwidth', async () => { - const transaction = createBase64Transaction('TransferContract'); + const transaction = getTransactionExample('native'); const availableEnergy = BigNumber(0); const availableBandwidth = BigNumber(1000); // Less than needed @@ -114,7 +153,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'TRX', type: 'tron:728126428/slip44:195', - amount: '80.000000', + amount: '758.000000', fungible: true, }, }, @@ -133,17 +172,10 @@ describe('FeeCalculatorService', () => { }); it('has enough bandwidth + has enough energy', async () => { - const transaction = createBase64Transaction('TriggerSmartContract', { - contract_address: 'a'.repeat(64), - data: '0xa9059cbb000000000000000000000000', - - owner_address: 'b'.repeat(64), - - call_value: 0, - }); + const transaction = getTransactionExample('trc20'); const availableEnergy = BigNumber(100000); // More than needed (55001) - const availableBandwidth = BigNumber(1000000); // More than needed + const availableBandwidth = BigNumber(2000000); // More than needed for TRC20 transaction const result = await feeCalculatorService.computeFee({ scope: Network.Mainnet, @@ -168,7 +200,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'BANDWIDTH', type: 'tron:728126428/slip44:bandwidth', - amount: '311000', + amount: '1083000', fungible: true, }, }, @@ -176,17 +208,10 @@ describe('FeeCalculatorService', () => { }); it('has enough bandwidth + not enough energy', async () => { - const transaction = createBase64Transaction('TriggerSmartContract', { - contract_address: 'a'.repeat(64), - data: '0xa9059cbb000000000000000000000000', - - owner_address: 'b'.repeat(64), - - call_value: 0, - }); + const transaction = getTransactionExample('trc20'); const availableEnergy = BigNumber(30000); // Less than needed (55001) - const availableBandwidth = BigNumber(1000000); // More than needed + const availableBandwidth = BigNumber(2000000); // More than needed for TRC20 transaction const result = await feeCalculatorService.computeFee({ scope: Network.Mainnet, @@ -211,7 +236,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'BANDWIDTH', type: 'tron:728126428/slip44:bandwidth', - amount: '311000', + amount: '1083000', fungible: true, }, }, @@ -228,15 +253,7 @@ describe('FeeCalculatorService', () => { }); it('not enough bandwidth + has enough energy', async () => { - const largeTransaction = createBase64Transaction( - 'TriggerSmartContract', - { - contract_address: 'a'.repeat(64), - data: 'b'.repeat(2000), // Large data to increase bandwidth - owner_address: 'c'.repeat(64), - call_value: 0, - }, - ); + const largeTransaction = createLargeTransaction(); const availableEnergy = BigNumber(100000); // More than needed (55001) const availableBandwidth = BigNumber(1000); // Less than needed @@ -264,7 +281,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'TRX', type: 'tron:728126428/slip44:195', - amount: '2277.000000', + amount: '2947.000000', fungible: true, }, }, @@ -272,15 +289,7 @@ describe('FeeCalculatorService', () => { }); it('not enough bandwidth + not enough energy', async () => { - const largeTransaction = createBase64Transaction( - 'TriggerSmartContract', - { - contract_address: 'a'.repeat(64), - data: 'b'.repeat(2000), // Large data to increase bandwidth - owner_address: 'c'.repeat(64), - call_value: 0, - }, - ); + const largeTransaction = createLargeTransaction(); const availableEnergy = BigNumber(30000); // Less than needed (55001) const availableBandwidth = BigNumber(1000); // Less than needed @@ -309,7 +318,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'TRX', type: 'tron:728126428/slip44:195', - amount: '2279.500100', + amount: '2949.500100', fungible: true, }, }, @@ -319,7 +328,7 @@ describe('FeeCalculatorService', () => { describe('Edge cases and error handling', () => { it('should filter out zero amount fees', async () => { - const transaction = createBase64Transaction('TransferContract'); + const transaction = getTransactionExample('native'); const availableEnergy = BigNumber(0); const availableBandwidth = BigNumber(0); @@ -337,7 +346,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'TRX', type: 'tron:728126428/slip44:195', - amount: '80.000000', + amount: '758.000000', fungible: true, }, }, @@ -345,7 +354,7 @@ describe('FeeCalculatorService', () => { }); it('should handle different networks correctly', async () => { - const transaction = createBase64Transaction('TransferContract'); + const transaction = getTransactionExample('native'); const availableEnergy = BigNumber(100000); const availableBandwidth = BigNumber(1000000); @@ -362,7 +371,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'BANDWIDTH', type: 'tron:2494104990/slip44:bandwidth', - amount: '80000', + amount: '758000', fungible: true, }, }, @@ -372,12 +381,7 @@ describe('FeeCalculatorService', () => { it('should use fallback values when chain parameters are missing', async () => { mockTrongridApiClient.getChainParameters.mockResolvedValue([]); - const transaction = createBase64Transaction('TriggerSmartContract', { - contract_address: 'a'.repeat(64), - data: '0xa9059cbb000000000000000000000000', - owner_address: 'b'.repeat(64), - call_value: 0, - }); + const transaction = getTransactionExample('trc20'); mockTronWebClient.transactionBuilder.triggerConstantContract.mockResolvedValue( { @@ -386,7 +390,7 @@ describe('FeeCalculatorService', () => { ); const availableEnergy = BigNumber(100000); - const availableBandwidth = BigNumber(1000000); + const availableBandwidth = BigNumber(2000000); // Ensure enough bandwidth const result = await feeCalculatorService.computeFee({ scope: Network.Mainnet, @@ -411,7 +415,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'BANDWIDTH', type: 'tron:728126428/slip44:bandwidth', - amount: '311000', + amount: '1083000', fungible: true, }, }, @@ -428,17 +432,10 @@ describe('FeeCalculatorService', () => { }); it('should handle very large transactions', async () => { - const veryLargeTransaction = createBase64Transaction( - 'TriggerSmartContract', - { - contract_address: 'a'.repeat(64), - data: 'b'.repeat(10000), // Very large data - - owner_address: 'c'.repeat(64), - - call_value: 0, - }, - ); + const veryLargeTransaction = createLargeTransaction(); + // Make it even larger for this test + veryLargeTransaction.raw_data.contract[0].parameter.value.data = + 'b'.repeat(10000); mockTronWebClient.transactionBuilder.triggerConstantContract.mockResolvedValue( { @@ -471,7 +468,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'TRX', type: 'tron:728126428/slip44:195', - amount: '10277.000000', + amount: '10947.000000', fungible: true, }, }, @@ -479,9 +476,9 @@ describe('FeeCalculatorService', () => { }); it('should handle exact resource matches', async () => { - const transaction = createBase64Transaction('TransferContract'); + const transaction = getTransactionExample('native'); const availableEnergy = BigNumber(0); - const availableBandwidth = BigNumber(80000); + const availableBandwidth = BigNumber(758000); // Exact match for native transaction const result = await feeCalculatorService.computeFee({ scope: Network.Mainnet, @@ -496,7 +493,7 @@ describe('FeeCalculatorService', () => { asset: { unit: 'BANDWIDTH', type: 'tron:728126428/slip44:bandwidth', - amount: '80000', + amount: '758000', fungible: true, }, }, diff --git a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts index bf1853f8..a40b4622 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts @@ -1,6 +1,10 @@ import { FeeType } from '@metamask/keyring-api'; import { BigNumber } from 'bignumber.js'; -import type { Contract } from 'tronweb'; +import type { + Transaction, + TransactionContract, + TriggerSmartContract, +} from 'tronweb/lib/esm/types'; import type { ComputeFeeResult } from './types'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; @@ -40,27 +44,18 @@ export class FeeCalculatorService { */ async #calculateEnergy( scope: Network, - transaction: string, + transaction: Transaction, ): Promise { try { - // Decode the transaction from base64 - let txObj: any = null; - - try { - // eslint-disable-next-line no-restricted-globals - txObj = JSON.parse(Buffer.from(transaction, 'base64').toString()); - } catch (parseError) { - this.#logger.warn({ error: parseError }, 'Failed to parse transaction'); - throw new Error('Invalid transaction format'); - } + const contract = transaction.raw_data.contract[0]; - if (!txObj?.raw_data?.contract?.[0]) { - throw new Error('Invalid transaction structure'); + if (!contract) { + throw new Error( + 'Cannot calculate energy: No contract found in transaction', + ); } - const contract = txObj.raw_data.contract[0]; - const contractType = contract.type; - + const contractType = contract.type as string; this.#logger.log(`Calculating energy for contract type: ${contractType}`); switch (contractType) { @@ -75,7 +70,10 @@ export class FeeCalculatorService { case 'TriggerSmartContract': { // For smart contracts, try to estimate energy using triggerconstantcontract return BigNumber( - await this.#estimateSmartContractEnergy(scope, contract), + await this.#estimateSmartContractEnergy( + scope, + contract as TransactionContract, + ), ); } @@ -98,10 +96,9 @@ export class FeeCalculatorService { * @param transaction - The base64 encoded transaction * @returns number - The calculated bandwidth in SUN */ - #calculateBandwidth(transaction: string): BigNumber { + #calculateBandwidth(transaction: Transaction): BigNumber { // eslint-disable-next-line no-restricted-globals - const decodedBytes = Buffer.from(transaction, 'base64'); - const byteSize = decodedBytes.length; + const byteSize = Buffer.byteLength(JSON.stringify(transaction)); return BigNumber(byteSize * 1000); } @@ -115,7 +112,7 @@ export class FeeCalculatorService { */ async #estimateSmartContractEnergy( scope: Network, - contract: Contract, + contract: TransactionContract, ): Promise { try { this.#logger.log( @@ -125,11 +122,29 @@ export class FeeCalculatorService { const tronWeb = this.#tronWebFactory.createClient(scope); + // Example TriggerSmartContract structure: + // "contract": [ + // { + // "parameter": { + // "value": { + // "data": "a9059cbb0000000000000000000000007c7ec04a5297bb92305ebcf776e59876be0ca53b00000000000000000000000000000000000000000000000002c68af0bb140000", + // "owner_address": "413986cff58bc3066e62f43f2e32f603d026a43726", + // "contract_address": "41e91a7411e56ce79e83570570f49b9fc35b7727c5" + // }, + // "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + // }, + // "type": "TriggerSmartContract" + // } + // ] const contractAddress = contract.parameter.value.contract_address; const functionSelector = contract.parameter.value.data; const ownerAddress = contract.parameter.value.owner_address; const callValue = contract.parameter.value.call_value ?? 0; + if (!functionSelector) { + throw new Error('Cannot estimate energy: No function selector found'); + } + // Convert addresses from hex to base58 if needed const contractAddressBase58 = tronWeb.address.fromHex( `41${contractAddress}`, @@ -199,7 +214,7 @@ export class FeeCalculatorService { availableBandwidth, }: { scope: Network; - transaction: string; + transaction: Transaction; availableEnergy: BigNumber; availableBandwidth: BigNumber; }): Promise { diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts index abe72aa0..23fc66df 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts @@ -78,7 +78,7 @@ describe('TransactionMapper', () => { fees: [ { asset: { - amount: '266000', + amount: '0.266', unit: 'TRX', type: 'tron:728126428/slip44:195', fungible: true, @@ -152,7 +152,7 @@ describe('TransactionMapper', () => { fees: [ { asset: { - amount: '281000', + amount: '0.281', unit: 'TRX', type: 'tron:728126428/slip44:195', fungible: true, @@ -222,7 +222,7 @@ describe('TransactionMapper', () => { fees: [ { asset: { - amount: '12987800', + amount: '12.9878', unit: 'TRX', type: 'tron:728126428/slip44:195', fungible: true, @@ -286,7 +286,7 @@ describe('TransactionMapper', () => { const expectedFees = [ { asset: { - amount: '266000', + amount: '0.266', unit: 'TRX', type: 'tron:728126428/slip44:195', fungible: true, @@ -318,7 +318,7 @@ describe('TransactionMapper', () => { const expectedFees = [ { asset: { - amount: '12987800', + amount: '12.9878', unit: 'TRX', type: 'tron:728126428/slip44:195', fungible: true, @@ -499,7 +499,7 @@ describe('TransactionMapper', () => { fees: [ { asset: { - amount: '266000', + amount: '0.266', unit: 'TRX', type: 'tron:2494104990/slip44:195', fungible: true, diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts index e48a6bba..122506cd 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts @@ -32,10 +32,11 @@ export class TransactionMapper { } = Networks[network]; // Base TRX fee calculation - const transactionFee = transactionInfo.ret.reduce( + const transactionFeeInSun = transactionInfo.ret.reduce( (total, result) => total + (result.fee || 0), 0, ); + const transactionFee = transactionFeeInSun / 1_000_000; const setFeeIfPresent = ( amount: number, From a33a2da1fec64e8fcdecee5003c2f167f981636b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:25:19 +0000 Subject: [PATCH 072/238] 1.7.2 (#78) This is the release candidate for version 1.7.2. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 26 ++++++++++++++++--- merged-packages/tron-wallet-snap/package.json | 2 +- .../tron-wallet-snap/snap.manifest.json | 4 +-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index ddcbde0f..e1c443c8 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,12 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.2] + +### Added + +- Use Infura for all API dependencies ([#75](https://github.com/MetaMask/snap-tron-wallet/pull/75)) + +### Fixed + +- Return transaction history fees in TRX not in SUN ([#77](https://github.com/MetaMask/snap-tron-wallet/pull/77)) +- `computeFee` method needs to reconstruct Tron transactions the same way `signAndSendTransaction` does ([#77](https://github.com/MetaMask/snap-tron-wallet/pull/77)) +- Adjust decimals when sending TRC20 tokens ([#76](https://github.com/MetaMask/snap-tron-wallet/pull/76)) + ## [1.7.1] -### Uncategorized +### Changed + +- Remove unused "Localnet" ([#73](https://github.com/MetaMask/snap-tron-wallet/pull/73)) + +### Fixed -- All improvements ([#73](https://github.com/MetaMask/snap-tron-wallet/pull/73)) -- fix: staking methods need to convert amounts to sun ([#71](https://github.com/MetaMask/snap-tron-wallet/pull/71)) +- Incorrect staked Tron amount due to not counting delegated TRX ([#73](https://github.com/MetaMask/snap-tron-wallet/pull/73)) +- No initialized placeholder TRX value, nor special assets (Bandwidth, Energy) on accounts without TRX ([#73](https://github.com/MetaMask/snap-tron-wallet/pull/73)) +- Staking methods need to convert amounts to sun ([#71](https://github.com/MetaMask/snap-tron-wallet/pull/71)) ## [1.7.0] @@ -140,7 +157,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.2...HEAD +[1.7.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.1...v1.7.2 [1.7.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.0...v1.7.1 [1.7.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.1...v1.7.0 [1.6.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.0...v1.6.1 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 460af50a..57aca03c 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.7.1", + "version": "1.7.2", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 468fd4bc..5042f2a4 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.7.1", + "version": "1.7.2", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "a4EolVm0o2wBkluEZ6/bvpBB0d6waiwNIAPiY7C/b98=", + "shasum": "hD3WJnOISt3DK9eDdCJMd3WReDg4q2UvgFlrWP/dOT0=", "location": { "npm": { "filePath": "dist/bundle.js", From 9173fc1bb0668252225e644c03dc568e8eb953c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 13:14:25 +0100 Subject: [PATCH 073/238] 1.7.3 (#79) This is the release candidate for version 1.7.3. --------- Co-authored-by: github-actions Co-authored-by: Alejandro Garcia Anglada --- merged-packages/tron-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 5042f2a4..cd458463 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "hD3WJnOISt3DK9eDdCJMd3WReDg4q2UvgFlrWP/dOT0=", + "shasum": "sHiZVb8GmSIrnQwehZRgIJvSao4qyWjXQDk47HbAiUE=", "location": { "npm": { "filePath": "dist/bundle.js", From f8696472fd80aed23e038b58fb97f7580d263059 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Tue, 11 Nov 2025 13:44:01 +0100 Subject: [PATCH 074/238] Revert "1.7.3" (#80) Reverts MetaMask/snap-tron-wallet#79 --- merged-packages/tron-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index cd458463..5042f2a4 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "sHiZVb8GmSIrnQwehZRgIJvSao4qyWjXQDk47HbAiUE=", + "shasum": "hD3WJnOISt3DK9eDdCJMd3WReDg4q2UvgFlrWP/dOT0=", "location": { "npm": { "filePath": "dist/bundle.js", From 495e60478a75adafabf8002ef27b2732100bfe09 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:57:35 +0100 Subject: [PATCH 075/238] 1.7.3 (#81) This is the release candidate for version 1.7.3. --------- Co-authored-by: github-actions Co-authored-by: Alejandro Garcia Anglada Co-authored-by: Alejandro Garcia --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index e1c443c8..ce9721a7 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.3] + +### Added + +- Use Infura urls ([#75](https://github.com/MetaMask/snap-tron-wallet/pull/75)) + ## [1.7.2] ### Added @@ -157,7 +163,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.2...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.3...HEAD +[1.7.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.2...v1.7.3 [1.7.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.1...v1.7.2 [1.7.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.0...v1.7.1 [1.7.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.6.1...v1.7.0 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index 57aca03c..b05b76e9 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.7.2", + "version": "1.7.3", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 5042f2a4..99e067be 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.7.2", + "version": "1.7.3", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "hD3WJnOISt3DK9eDdCJMd3WReDg4q2UvgFlrWP/dOT0=", + "shasum": "f0KQQ5kxO+Q52owKYzWcVyDePAbjCtA6vW5esHveHNU=", "location": { "npm": { "filePath": "dist/bundle.js", From 32917020f34692de02e22fb6e8e35c9f66782cc1 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 11 Nov 2025 14:55:32 +0000 Subject: [PATCH 076/238] fix: unstake method doing incorrect input validation (#82) --- merged-packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/clientRequest/clientRequest.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 99e067be..dd752c64 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "f0KQQ5kxO+Q52owKYzWcVyDePAbjCtA6vW5esHveHNU=", + "shasum": "ercxqe12DSndDhxfm3rtzmJKBoFiBd6aPxgAaMjz7lQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index a3c6a780..910e5999 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -29,6 +29,7 @@ import { OnAmountInputRequestStruct, OnConfirmSendRequestStruct, OnConfirmStakeRequestStruct, + OnConfirmUnstakeRequestStruct, OnStakeAmountInputRequestStruct, OnUnstakeAmountInputRequestStruct, SignAndSendTransactionRequestStruct, @@ -588,7 +589,7 @@ export class ClientRequestHandler { async #handleConfirmUnstake(request: JsonRpcRequest): Promise { assertOrThrow( request, - OnUnstakeAmountInputRequestStruct, + OnConfirmUnstakeRequestStruct, new InvalidParamsError(), ); From c8ae61882e467286e370fad2ace79f63856ac1e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:04:31 +0000 Subject: [PATCH 077/238] 1.7.4 (#83) This is the release candidate for version 1.7.4. --------- Co-authored-by: github-actions Co-authored-by: Ulisses Ferreira --- merged-packages/tron-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/tron-wallet-snap/package.json | 2 +- merged-packages/tron-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/tron-wallet-snap/CHANGELOG.md b/merged-packages/tron-wallet-snap/CHANGELOG.md index ce9721a7..a0654c71 100644 --- a/merged-packages/tron-wallet-snap/CHANGELOG.md +++ b/merged-packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.4] + +### Fixed + +- Unstake method was doing incorrect input validation ([#82](https://github.com/MetaMask/snap-tron-wallet/pull/82)) + ## [1.7.3] ### Added @@ -163,7 +169,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of Tron wallet snap - Support for TRX and token assets balances ([#12](https://github.com/MetaMask/snap-tron-wallet/pull/12)) -[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.3...HEAD +[Unreleased]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.4...HEAD +[1.7.4]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.3...v1.7.4 [1.7.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.2...v1.7.3 [1.7.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.1...v1.7.2 [1.7.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.0...v1.7.1 diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json index b05b76e9..7422eaaf 100644 --- a/merged-packages/tron-wallet-snap/package.json +++ b/merged-packages/tron-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/tron-wallet-snap", - "version": "1.7.3", + "version": "1.7.4", "description": "A Tron wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index dd752c64..e8488e96 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.7.3", + "version": "1.7.4", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "ercxqe12DSndDhxfm3rtzmJKBoFiBd6aPxgAaMjz7lQ=", + "shasum": "KM0Z0sW6URiwLG59FGoYVnUMjfCMNqPbx0kUAU0pN88=", "location": { "npm": { "filePath": "dist/bundle.js", From 2454c9daee83d0b0fe47ab1278a580647b1d57e2 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 14 Nov 2025 11:57:30 +0000 Subject: [PATCH 078/238] feat: implement confirmation UI (#86) Screenshot 2025-11-13 at 17 43 32 Screenshot 2025-11-14 at 00 22 48 --------- Co-authored-by: Antonio Regadas --- .../tron-wallet-snap/images/question-mark.svg | 3 + .../tron-wallet-snap/locales/de.json | 47 +++++ .../tron-wallet-snap/locales/el.json | 47 +++++ .../tron-wallet-snap/locales/en.json | 154 +++----------- .../tron-wallet-snap/locales/es.json | 47 +++++ .../tron-wallet-snap/locales/fr.json | 47 +++++ .../tron-wallet-snap/locales/hi.json | 47 +++++ .../tron-wallet-snap/locales/id.json | 47 +++++ .../tron-wallet-snap/locales/ja.json | 47 +++++ .../tron-wallet-snap/locales/ko.json | 47 +++++ .../tron-wallet-snap/locales/pt.json | 47 +++++ .../tron-wallet-snap/locales/ru.json | 47 +++++ .../tron-wallet-snap/locales/tl.json | 47 +++++ .../tron-wallet-snap/locales/tr.json | 47 +++++ .../tron-wallet-snap/locales/vi.json | 47 +++++ .../tron-wallet-snap/locales/zh.json | 47 +++++ .../tron-wallet-snap/messages.json | 153 +++----------- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/snap/SnapClient.ts | 44 ++++ .../tron-wallet-snap/src/context.ts | 25 ++- .../tron-wallet-snap/src/entities/assets.ts | 2 + .../tron-wallet-snap/src/handlers/assets.ts | 16 +- .../handlers/clientRequest/clientRequest.ts | 170 ++++++++------- .../src/handlers/userInput.ts | 58 +++++- .../src/services/assets/AssetsService.ts | 106 +++++++++- .../confirmation/ConfirmationHandler.ts | 41 ++++ .../send/FeeCalculatorService.test.ts | 38 ++++ .../src/services/send/FeeCalculatorService.ts | 5 + .../src/services/send/SendService.ts | 197 ++++++++++++++++++ .../src/services/send/types.ts | 15 +- .../src/services/staking/StakingService.ts | 23 +- .../tron-wallet-snap/src/static/tron-logo.ts | 1 + .../confirmation/components/Asset/Asset.tsx | 28 +++ .../src/ui/confirmation/components/Fees.tsx | 57 +++++ .../utils/getIconUrlForKnownAsset.ts | 17 ++ .../ConfirmTransactionRequest.tsx | 143 +++++++++++++ .../ConfirmTransactionRequest/events.tsx | 55 +++++ .../ConfirmTransactionRequest/render.tsx | 120 +++++++++++ .../views/ConfirmTransactionRequest/types.ts | 16 ++ .../src/ui/utils/generateImageComponent.ts | 28 +++ .../tron-wallet-snap/src/utils/errors.ts | 4 - 41 files changed, 1800 insertions(+), 379 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/images/question-mark.svg create mode 100644 merged-packages/tron-wallet-snap/locales/de.json create mode 100644 merged-packages/tron-wallet-snap/locales/el.json create mode 100644 merged-packages/tron-wallet-snap/locales/es.json create mode 100644 merged-packages/tron-wallet-snap/locales/fr.json create mode 100644 merged-packages/tron-wallet-snap/locales/hi.json create mode 100644 merged-packages/tron-wallet-snap/locales/id.json create mode 100644 merged-packages/tron-wallet-snap/locales/ja.json create mode 100644 merged-packages/tron-wallet-snap/locales/ko.json create mode 100644 merged-packages/tron-wallet-snap/locales/pt.json create mode 100644 merged-packages/tron-wallet-snap/locales/ru.json create mode 100644 merged-packages/tron-wallet-snap/locales/tl.json create mode 100644 merged-packages/tron-wallet-snap/locales/tr.json create mode 100644 merged-packages/tron-wallet-snap/locales/vi.json create mode 100644 merged-packages/tron-wallet-snap/locales/zh.json create mode 100644 merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts create mode 100644 merged-packages/tron-wallet-snap/src/static/tron-logo.ts create mode 100644 merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx create mode 100644 merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx create mode 100644 merged-packages/tron-wallet-snap/src/ui/confirmation/utils/getIconUrlForKnownAsset.ts create mode 100644 merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx create mode 100644 merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/events.tsx create mode 100644 merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx create mode 100644 merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts create mode 100644 merged-packages/tron-wallet-snap/src/ui/utils/generateImageComponent.ts diff --git a/merged-packages/tron-wallet-snap/images/question-mark.svg b/merged-packages/tron-wallet-snap/images/question-mark.svg new file mode 100644 index 00000000..8d216574 --- /dev/null +++ b/merged-packages/tron-wallet-snap/images/question-mark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/merged-packages/tron-wallet-snap/locales/de.json b/merged-packages/tron-wallet-snap/locales/de.json new file mode 100644 index 00000000..2114fcf9 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/de.json @@ -0,0 +1,47 @@ +{ + "locale": "de", + "messages": { + "confirmation.transaction.title": { + "message": "Transaktionsanfrage" + }, + "confirmation.estimatedChanges.title": { + "message": "Geschätzte Änderungen" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Die geschätzten Änderungen beziehen sich auf das, was passieren könnte, wenn Sie diese Transaktion durchführen. Es handelt sich hierbei lediglich um eine Prognose, nicht um eine Garantie." + }, + "confirmation.estimatedChanges.send": { + "message": "Sie senden" + }, + "confirmation.bandwidthConsumed": { + "message": "Verbrauchte Bandbreite" + }, + "confirmation.estimatedChanges.receive": { + "message": "Sie empfangen" + }, + "confirmation.origin": { + "message": "Anfrage von" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Konto" + }, + "confirmation.recipient": { + "message": "Empfänger" + }, + "confirmation.network": { + "message": "Netzwerk" + }, + "confirmation.transactionFee": { + "message": "Netzwerk-Gebühr" + }, + "confirmation.confirmButton": { + "message": "Bestätigen" + }, + "confirmation.cancelButton": { + "message": "Stornieren" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/el.json b/merged-packages/tron-wallet-snap/locales/el.json new file mode 100644 index 00000000..11879edd --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/el.json @@ -0,0 +1,47 @@ +{ + "locale": "el", + "messages": { + "confirmation.transaction.title": { + "message": "Αίτημα συναλλαγής" + }, + "confirmation.estimatedChanges.title": { + "message": "Εκτιμώμενες αλλαγές" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Οι εκτιμώμενες αλλαγές είναι αυτές που μπορεί να συμβούν αν προχωρήσετε σε αυτή τη συναλλαγή. Πρόκειται απλώς για μια πρόβλεψη, δεν αποτελεί εγγύηση." + }, + "confirmation.estimatedChanges.send": { + "message": "Θα στείλετε" + }, + "confirmation.bandwidthConsumed": { + "message": "Καταναλωμένο εύρος ζώνης" + }, + "confirmation.estimatedChanges.receive": { + "message": "Θα λάβετε" + }, + "confirmation.origin": { + "message": "Ζητήθηκε από" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Λογαριασμός" + }, + "confirmation.recipient": { + "message": "Παραλήπτης" + }, + "confirmation.network": { + "message": "Δίκτυο" + }, + "confirmation.transactionFee": { + "message": "Τέλη δικτύου" + }, + "confirmation.confirmButton": { + "message": "Επιβεβαίωση" + }, + "confirmation.cancelButton": { + "message": "Άκυρο" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/en.json b/merged-packages/tron-wallet-snap/locales/en.json index 92359d14..a25b972f 100644 --- a/merged-packages/tron-wallet-snap/locales/en.json +++ b/merged-packages/tron-wallet-snap/locales/en.json @@ -1,149 +1,47 @@ { "locale": "en", "messages": { - "reviewTransactionWarning": { - "message": "Review the transaction before proceeding" + "confirmation.transaction.title": { + "message": "Transaction request" }, - "from": { - "message": "From" + "confirmation.estimatedChanges.title": { + "message": "Estimated changes" }, - "toAddress": { - "message": "To" + "confirmation.estimatedChanges.tooltip": { + "message": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee." }, - "continue": { - "message": "Continue" + "confirmation.estimatedChanges.send": { + "message": "You send" }, - "cancel": { - "message": "Cancel" + "confirmation.bandwidthConsumed": { + "message": "Bandwidth consumed" + }, + "confirmation.estimatedChanges.receive": { + "message": "You receive" }, - "clear": { - "message": "Clear" + "confirmation.origin": { + "message": "Request from" }, - "amount": { - "message": "Amount" + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." }, - "balance": { - "message": "Balance" + "confirmation.account": { + "message": "Account" }, - "recipient": { + "confirmation.recipient": { "message": "Recipient" }, - "network": { + "confirmation.network": { "message": "Network" }, - "minutes": { - "message": "min" - }, - "transactionSpeed": { - "message": "Transaction speed" - }, - "transactionSpeedTooltip": { - "message": "The estimated time of the transaction" - }, - "networkFee": { + "confirmation.transactionFee": { "message": "Network fee" }, - "feeRate": { - "message": "Fee rate" - }, - "networkFeeTooltip": { - "message": "The total network fee" - }, - "total": { - "message": "Total" - }, - "send": { - "message": "Send" - }, - "asset": { - "message": "Asset" - }, - "sending": { - "message": "Sending" - }, - "max": { - "message": "Max" - }, - "recipientPlaceholder": { - "message": "Enter receiving address" - }, - "review": { - "message": "Review" - }, - "error": { - "message": "Error" - }, - "unknownError": { - "message": "An unknown error occurred" - }, - "feeTooLow": { - "message": "Fee too low" - }, - "feeRateTooLow": { - "message": "Fee rate too low" - }, - "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" - }, - "outputBelowDustLimit": { - "message": "Amount below dust limit" + "confirmation.confirmButton": { + "message": "Confirm" }, - "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" - }, - "noRecipients": { - "message": "Missing recipients" - }, - "psbt": { - "message": "Invalid PSBT" - }, - "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" - }, - "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" - }, - "base58": { - "message": "Invalid Bitcoin address" - }, - "bech32": { - "message": "Invalid Bitcoin address" - }, - "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" - }, - "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" - }, - "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" - }, - "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" - }, - "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" - }, - "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" - }, - "outOfRange": { - "message": "Invalid amount: out of range" - }, - "tooPrecise": { - "message": "Invalid amount: too precise" - }, - "missingDigits": { - "message": "Invalid amount: missing digits" - }, - "inputTooLarge": { - "message": "Invalid amount: too large" - }, - "invalidCharacter": { - "message": "Invalid amount: invalid character" - }, - "unexpected": { - "message": "An unexpected error occurred" + "confirmation.cancelButton": { + "message": "Cancel" } } } diff --git a/merged-packages/tron-wallet-snap/locales/es.json b/merged-packages/tron-wallet-snap/locales/es.json new file mode 100644 index 00000000..91fd027b --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/es.json @@ -0,0 +1,47 @@ +{ + "locale": "es", + "messages": { + "confirmation.transaction.title": { + "message": "Solicitud de transacción" + }, + "confirmation.estimatedChanges.title": { + "message": "Cambios estimados" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Los cambios estimados son los que podrían producirse si sigue adelante con esta transacción. Esto es solo una predicción, no una garantía." + }, + "confirmation.estimatedChanges.send": { + "message": "Usted envía" + }, + "confirmation.bandwidthConsumed": { + "message": "Ancho de banda consumido" + }, + "confirmation.estimatedChanges.receive": { + "message": "Usted recibe" + }, + "confirmation.origin": { + "message": "Solicitud de" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Cuenta" + }, + "confirmation.recipient": { + "message": "Destinatario" + }, + "confirmation.network": { + "message": "Red" + }, + "confirmation.transactionFee": { + "message": "Tarifa de red" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.cancelButton": { + "message": "Cancelar" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/fr.json b/merged-packages/tron-wallet-snap/locales/fr.json new file mode 100644 index 00000000..d2db9f79 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/fr.json @@ -0,0 +1,47 @@ +{ + "locale": "fr", + "messages": { + "confirmation.transaction.title": { + "message": "Demande de transaction" + }, + "confirmation.estimatedChanges.title": { + "message": "Changements estimés" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Les changements estimés représentent ce qui pourrait se produire si vous effectuez cette transaction. Il s’agit juste d’une estimation fournie à titre d’information." + }, + "confirmation.estimatedChanges.send": { + "message": "Vous envoyez" + }, + "confirmation.bandwidthConsumed": { + "message": "Bande passante utilisée" + }, + "confirmation.estimatedChanges.receive": { + "message": "Vous recevez" + }, + "confirmation.origin": { + "message": "Demande de la part de" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Compte" + }, + "confirmation.recipient": { + "message": "Destinataire" + }, + "confirmation.network": { + "message": "Réseau" + }, + "confirmation.transactionFee": { + "message": "Frais de réseau" + }, + "confirmation.confirmButton": { + "message": "Confirmer" + }, + "confirmation.cancelButton": { + "message": "Annuler" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/hi.json b/merged-packages/tron-wallet-snap/locales/hi.json new file mode 100644 index 00000000..3f2a919b --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/hi.json @@ -0,0 +1,47 @@ +{ + "locale": "hi", + "messages": { + "confirmation.transaction.title": { + "message": "ट्रांसेक्शन रिक्वेस्ट" + }, + "confirmation.estimatedChanges.title": { + "message": "अनुमानित बदलाव" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "अगर आप यह ट्रांसेक्शन करते हैं तो अनुमानित परिवर्तन हो सकते हैं। यह सिर्फ एक प्रेडिक्शन है, कोई गारंटी नहीं।" + }, + "confirmation.estimatedChanges.send": { + "message": "आप भेजते हैं" + }, + "confirmation.bandwidthConsumed": { + "message": "खपत की गई बैंडविड्थ" + }, + "confirmation.estimatedChanges.receive": { + "message": "आप प्राप्त करते हैं" + }, + "confirmation.origin": { + "message": "इनसे मिला अनुरोध" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "अकाउंट" + }, + "confirmation.recipient": { + "message": "द्वारा प्राप्त किया गया" + }, + "confirmation.network": { + "message": "नेटवर्क" + }, + "confirmation.transactionFee": { + "message": "नेटवर्क फीस" + }, + "confirmation.confirmButton": { + "message": "कन्फर्म करें" + }, + "confirmation.cancelButton": { + "message": "कैंसिल करें" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/id.json b/merged-packages/tron-wallet-snap/locales/id.json new file mode 100644 index 00000000..9e752a19 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/id.json @@ -0,0 +1,47 @@ +{ + "locale": "id", + "messages": { + "confirmation.transaction.title": { + "message": "Permintaan transaksi" + }, + "confirmation.estimatedChanges.title": { + "message": "Estimasi perubahan" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Estimasi perubahan merupakan hal yang mungkin terjadi jika Anda melakukan transaksi ini. Ini hanyalah prediksi, bukan jaminan." + }, + "confirmation.estimatedChanges.send": { + "message": "Anda mengirim" + }, + "confirmation.bandwidthConsumed": { + "message": "Bandwidth yang digunakan" + }, + "confirmation.estimatedChanges.receive": { + "message": "Anda menerima" + }, + "confirmation.origin": { + "message": "Permintaan dari" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Akun" + }, + "confirmation.recipient": { + "message": "Penerima" + }, + "confirmation.network": { + "message": "Jaringan" + }, + "confirmation.transactionFee": { + "message": "Biaya jaringan" + }, + "confirmation.confirmButton": { + "message": "Konfirmasikan" + }, + "confirmation.cancelButton": { + "message": "Batal" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/ja.json b/merged-packages/tron-wallet-snap/locales/ja.json new file mode 100644 index 00000000..ffe9c1a8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/ja.json @@ -0,0 +1,47 @@ +{ + "locale": "ja", + "messages": { + "confirmation.transaction.title": { + "message": "トランザクションリクエスト" + }, + "confirmation.estimatedChanges.title": { + "message": "予測される増減額" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "予測される増減額は、このトランザクションを実行すると発生する可能性がある増減額です。これは単に予測に過ぎず、保証されたものではありません。" + }, + "confirmation.estimatedChanges.send": { + "message": "送金額" + }, + "confirmation.bandwidthConsumed": { + "message": "消費された帯域幅" + }, + "confirmation.estimatedChanges.receive": { + "message": "受取額" + }, + "confirmation.origin": { + "message": "要求元" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "アカウント" + }, + "confirmation.recipient": { + "message": "受取人" + }, + "confirmation.network": { + "message": "ネットワーク" + }, + "confirmation.transactionFee": { + "message": "ネットワーク手数料" + }, + "confirmation.confirmButton": { + "message": "確定" + }, + "confirmation.cancelButton": { + "message": "キャンセル" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/ko.json b/merged-packages/tron-wallet-snap/locales/ko.json new file mode 100644 index 00000000..262ad068 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/ko.json @@ -0,0 +1,47 @@ +{ + "locale": "ko", + "messages": { + "confirmation.transaction.title": { + "message": "트랜잭션 요청" + }, + "confirmation.estimatedChanges.title": { + "message": "예상 변동 사항" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "예상 변동 사항은 이 트랜잭션을 진행할 경우 발생하는 결과를 예측한 것입니다. 이는 예측일 뿐 결과를 보장하지는 않습니다." + }, + "confirmation.estimatedChanges.send": { + "message": "전송:" + }, + "confirmation.bandwidthConsumed": { + "message": "소모된 대역폭" + }, + "confirmation.estimatedChanges.receive": { + "message": "받음:" + }, + "confirmation.origin": { + "message": "요청자:" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "계정" + }, + "confirmation.recipient": { + "message": "수신자" + }, + "confirmation.network": { + "message": "네트워크" + }, + "confirmation.transactionFee": { + "message": "네트워크 수수료" + }, + "confirmation.confirmButton": { + "message": "컨펌" + }, + "confirmation.cancelButton": { + "message": "취소" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/pt.json b/merged-packages/tron-wallet-snap/locales/pt.json new file mode 100644 index 00000000..a258bbb1 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/pt.json @@ -0,0 +1,47 @@ +{ + "locale": "pt", + "messages": { + "confirmation.transaction.title": { + "message": "Solicitação de transação" + }, + "confirmation.estimatedChanges.title": { + "message": "Alterações estimadas" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Alterações estimadas são o que pode acontecer se você prosseguir com essa transação. Isso é apenas uma previsão, não uma garantia." + }, + "confirmation.estimatedChanges.send": { + "message": "Você envia" + }, + "confirmation.bandwidthConsumed": { + "message": "Largura de banda consumida" + }, + "confirmation.estimatedChanges.receive": { + "message": "Você recebe" + }, + "confirmation.origin": { + "message": "Solicitação de" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Conta" + }, + "confirmation.recipient": { + "message": "Beneficiário" + }, + "confirmation.network": { + "message": "Rede" + }, + "confirmation.transactionFee": { + "message": "Taxa de rede" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.cancelButton": { + "message": "Cancelar" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/ru.json b/merged-packages/tron-wallet-snap/locales/ru.json new file mode 100644 index 00000000..2066ac16 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/ru.json @@ -0,0 +1,47 @@ +{ + "locale": "ru", + "messages": { + "confirmation.transaction.title": { + "message": "Запрос транзакции" + }, + "confirmation.estimatedChanges.title": { + "message": "Прогнозируемые изменения" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Прогнозируемые изменения — это то, что может произойти, если вы завершите эту транзакцию. Это всего лишь прогноз, а не гарантия." + }, + "confirmation.estimatedChanges.send": { + "message": "Вы отправляете" + }, + "confirmation.bandwidthConsumed": { + "message": "Использованная пропускная способность" + }, + "confirmation.estimatedChanges.receive": { + "message": "Вы получаете" + }, + "confirmation.origin": { + "message": "Запрос от" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Счет" + }, + "confirmation.recipient": { + "message": "Получатель" + }, + "confirmation.network": { + "message": "Сеть" + }, + "confirmation.transactionFee": { + "message": "Комиссия сети" + }, + "confirmation.confirmButton": { + "message": "Подтвердить" + }, + "confirmation.cancelButton": { + "message": "Отмена" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/tl.json b/merged-packages/tron-wallet-snap/locales/tl.json new file mode 100644 index 00000000..81ebdffa --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/tl.json @@ -0,0 +1,47 @@ +{ + "locale": "tl", + "messages": { + "confirmation.transaction.title": { + "message": "Hiling na transaksyon" + }, + "confirmation.estimatedChanges.title": { + "message": "Tinatayang mga pagbabago" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Ang tinatayang mga pagbabago ay ang maaaring mangyari kung magpapatuloy ka sa transaksyong ito. Ito ay isang hula lamang, hindi isang garantiya." + }, + "confirmation.estimatedChanges.send": { + "message": "Nagpadala ka ng" + }, + "confirmation.bandwidthConsumed": { + "message": "Ginamit na bandwidth" + }, + "confirmation.estimatedChanges.receive": { + "message": "Nakatanggap ka ng" + }, + "confirmation.origin": { + "message": "Kahilingan mula sa/kay" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.recipient": { + "message": "Tatanggap" + }, + "confirmation.network": { + "message": "Network" + }, + "confirmation.transactionFee": { + "message": "Bayad sa network" + }, + "confirmation.confirmButton": { + "message": "Kumpirmahin" + }, + "confirmation.cancelButton": { + "message": "Kanselahin" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/tr.json b/merged-packages/tron-wallet-snap/locales/tr.json new file mode 100644 index 00000000..642b8792 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/tr.json @@ -0,0 +1,47 @@ +{ + "locale": "tr", + "messages": { + "confirmation.transaction.title": { + "message": "İşlem talebi" + }, + "confirmation.estimatedChanges.title": { + "message": "Tahmini değişiklikler" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Tahmini değişiklikler bu işlemi gerçekleştirirseniz meydana gelebilecek değişikliklerdir. Bu bir garanti değil, sadece bir tahmindir." + }, + "confirmation.estimatedChanges.send": { + "message": "Gönderdiğiniz" + }, + "confirmation.bandwidthConsumed": { + "message": "Kullanılan bant genişliği" + }, + "confirmation.estimatedChanges.receive": { + "message": "Aldığınız" + }, + "confirmation.origin": { + "message": "Talebi gönderen" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Hesap" + }, + "confirmation.recipient": { + "message": "Alıcı" + }, + "confirmation.network": { + "message": "Ağ" + }, + "confirmation.transactionFee": { + "message": "Ağ ücreti" + }, + "confirmation.confirmButton": { + "message": "Onayla" + }, + "confirmation.cancelButton": { + "message": "İptal" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/vi.json b/merged-packages/tron-wallet-snap/locales/vi.json new file mode 100644 index 00000000..e9713369 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/vi.json @@ -0,0 +1,47 @@ +{ + "locale": "en", + "messages": { + "confirmation.transaction.title": { + "message": "Yêu cầu giao dịch" + }, + "confirmation.estimatedChanges.title": { + "message": "Thay đổi ước tính" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "Thay đổi ước tính là những gì có thể xảy ra nếu bạn thực hiện giao dịch này. Đây chỉ là dự đoán, không phải là đảm bảo." + }, + "confirmation.estimatedChanges.send": { + "message": "Bạn gửi" + }, + "confirmation.bandwidthConsumed": { + "message": "Băng thông đã sử dụng" + }, + "confirmation.estimatedChanges.receive": { + "message": "Bạn nhận được" + }, + "confirmation.origin": { + "message": "Yêu cầu từ" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "Tài khoản" + }, + "confirmation.recipient": { + "message": "Người nhận" + }, + "confirmation.network": { + "message": "Mạng" + }, + "confirmation.transactionFee": { + "message": "Phí mạng" + }, + "confirmation.confirmButton": { + "message": "Xác nhận" + }, + "confirmation.cancelButton": { + "message": "Hủy" + } + } +} diff --git a/merged-packages/tron-wallet-snap/locales/zh.json b/merged-packages/tron-wallet-snap/locales/zh.json new file mode 100644 index 00000000..eacd5fb7 --- /dev/null +++ b/merged-packages/tron-wallet-snap/locales/zh.json @@ -0,0 +1,47 @@ +{ + "locale": "en", + "messages": { + "confirmation.transaction.title": { + "message": "交易请求" + }, + "confirmation.estimatedChanges.title": { + "message": "预计变化" + }, + "confirmation.estimatedChanges.tooltip": { + "message": "预期变化是指您完成该交易可能发生的变化。这只是预测,而不是保证。" + }, + "confirmation.estimatedChanges.send": { + "message": "您发送" + }, + "confirmation.bandwidthConsumed": { + "message": "已用带宽" + }, + "confirmation.estimatedChanges.receive": { + "message": "您收到" + }, + "confirmation.origin": { + "message": "请求来自" + }, + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." + }, + "confirmation.account": { + "message": "账户" + }, + "confirmation.recipient": { + "message": "接收者" + }, + "confirmation.network": { + "message": "网络" + }, + "confirmation.transactionFee": { + "message": "网络费" + }, + "confirmation.confirmButton": { + "message": "确认" + }, + "confirmation.cancelButton": { + "message": "取消" + } + } +} diff --git a/merged-packages/tron-wallet-snap/messages.json b/merged-packages/tron-wallet-snap/messages.json index 22b29caa..a1c47b36 100644 --- a/merged-packages/tron-wallet-snap/messages.json +++ b/merged-packages/tron-wallet-snap/messages.json @@ -1,146 +1,41 @@ { - "reviewTransactionWarning": { - "message": "Review the transaction before proceeding" + "confirmation.transaction.title": { + "message": "Transaction request" }, - "from": { - "message": "From" + "confirmation.estimatedChanges.title": { + "message": "Estimated changes" }, - "toAddress": { - "message": "To" + "confirmation.estimatedChanges.tooltip": { + "message": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee." }, - "continue": { - "message": "Continue" + "confirmation.estimatedChanges.send": { + "message": "You send" }, - "cancel": { - "message": "Cancel" + "confirmation.bandwidthConsumed": { + "message": "Bandwidth consumed" }, - "clear": { - "message": "Clear" + "confirmation.estimatedChanges.receive": { + "message": "You receive" }, - "amount": { - "message": "Amount" + "confirmation.origin": { + "message": "Request from" }, - "balance": { - "message": "Balance" + "confirmation.origin.tooltip": { + "message": "This is the site asking for your confirmation." }, - "recipient": { - "message": "Recipient" + "confirmation.account": { + "message": "Account" }, - "network": { + "confirmation.network": { "message": "Network" }, - "minutes": { - "message": "min" - }, - "transactionSpeed": { - "message": "Transaction speed" - }, - "transactionSpeedTooltip": { - "message": "The estimated time of the transaction" - }, - "networkFee": { + "confirmation.transactionFee": { "message": "Network fee" }, - "feeRate": { - "message": "Fee rate" - }, - "networkFeeTooltip": { - "message": "The total network fee" - }, - "total": { - "message": "Total" - }, - "send": { - "message": "Send" - }, - "asset": { - "message": "Asset" - }, - "sending": { - "message": "Sending" - }, - "max": { - "message": "Max" - }, - "recipientPlaceholder": { - "message": "Enter receiving address" - }, - "review": { - "message": "Review" - }, - "error": { - "message": "Error" - }, - "unknownError": { - "message": "An unknown error occurred" - }, - "feeTooLow": { - "message": "Fee too low" - }, - "feeRateTooLow": { - "message": "Fee rate too low" - }, - "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" - }, - "outputBelowDustLimit": { - "message": "Amount below dust limit" - }, - "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" + "confirmation.confirmButton": { + "message": "Confirm" }, - "noRecipients": { - "message": "Missing recipients" - }, - "psbt": { - "message": "Invalid PSBT" - }, - "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" - }, - "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" - }, - "base58": { - "message": "Invalid Bitcoin address" - }, - "bech32": { - "message": "Invalid Bitcoin address" - }, - "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" - }, - "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" - }, - "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" - }, - "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" - }, - "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" - }, - "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" - }, - "outOfRange": { - "message": "Invalid amount: out of range" - }, - "tooPrecise": { - "message": "Invalid amount: too precise" - }, - "missingDigits": { - "message": "Invalid amount: missing digits" - }, - "inputTooLarge": { - "message": "Invalid amount: too large" - }, - "invalidCharacter": { - "message": "Invalid amount: invalid character" - }, - "unexpected": { - "message": "An unexpected error occurred" + "confirmation.cancelButton": { + "message": "Cancel" } } diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index e8488e96..ad8912da 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "KM0Z0sW6URiwLG59FGoYVnUMjfCMNqPbx0kUAU0pN88=", + "shasum": "HQaGe9G9dM2fdhvG9miTkwWahXBNxZztxneiLX4XaQ0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts index b1758fc9..8c2e52e5 100644 --- a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts @@ -7,6 +7,7 @@ import type { GetInterfaceStateResult, Json, ResolveInterfaceResult, + UpdateInterfaceResult, } from '@metamask/snaps-sdk'; import type { Preferences } from '../../types/snap'; @@ -44,6 +45,49 @@ export class SnapClient { }); } + /** + * Create a UI interface with the provided UI component and context. + * + * @param ui - The UI component to render. + * @param context - The initial context object to associate with the interface. + * @returns The created interface id. + */ + async createInterface( + ui: any, + context: TContext & Record, + ): Promise { + return snap.request({ + method: 'snap_createInterface', + params: { + ui, + context, + }, + }); + } + + /** + * Update an existing UI interface with a new UI component and context. + * + * @param id - The interface id returned from createInterface. + * @param ui - The new UI component to render. + * @param context - The updated context object to associate with the interface. + * @returns The update interface result. + */ + async updateInterface( + id: string, + ui: any, + context: TContext & Record, + ): Promise { + return snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui, + context, + }, + }); + } + /** * Gets the state of an interactive interface by its ID. * diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 11fc762d..64c931b6 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -17,6 +17,7 @@ import { AccountsService } from './services/accounts/AccountsService'; import { AssetsRepository } from './services/assets/AssetsRepository'; import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; +import { ConfirmationHandler } from './services/confirmation/ConfirmationHandler'; import { FeeCalculatorService } from './services/send/FeeCalculatorService'; import { SendService } from './services/send/SendService'; import { StakingService } from './services/staking/StakingService'; @@ -91,10 +92,10 @@ const transactionsService = new TransactionsService({ const accountsService = new AccountsService({ logger, + snapClient, accountsRepository, configProvider, assetsService, - snapClient, transactionsService, }); @@ -106,15 +107,19 @@ const feeCalculatorService = new FeeCalculatorService({ const sendService = new SendService({ logger, + snapClient, accountsService, tronWebFactory, - snapClient, }); const stakingService = new StakingService({ logger, + snapClient, accountsService, tronWebFactory, +}); + +const confirmationHandler = new ConfirmationHandler({ snapClient, }); @@ -127,18 +132,19 @@ const assetsHandler = new AssetsHandler({ }); const clientRequestHandler = new ClientRequestHandler({ logger, + snapClient, accountsService, assetsService, sendService, tronWebFactory, feeCalculatorService, - snapClient, stakingService, + confirmationHandler, }); const cronHandler = new CronHandler({ logger, - accountsService, snapClient, + accountsService, }); const lifecycleHandler = new LifecycleHandler({ logger, @@ -152,19 +158,25 @@ const keyringHandler = new KeyringHandler({ transactionsService, }); const rpcHandler = new RpcHandler(); -const userInputHandler = new UserInputHandler(); +const userInputHandler = new UserInputHandler({ + logger, + snapClient, +}); export type SnapExecutionContext = { /** * Services */ state: State; + priceApiClient: PriceApiClient; + feeCalculatorService: FeeCalculatorService; assetsService: AssetsService; accountsService: AccountsService; transactionsService: TransactionsService; sendService: SendService; tronHttpClient: TronHttpClient; tronWebFactory: TronWebFactory; + confirmationHandler: ConfirmationHandler; /** * Handlers */ @@ -182,12 +194,15 @@ const snapContext: SnapExecutionContext = { * Services */ state, + priceApiClient, + feeCalculatorService, assetsService, accountsService, transactionsService, sendService, tronHttpClient, tronWebFactory, + confirmationHandler, /** * Handlers */ diff --git a/merged-packages/tron-wallet-snap/src/entities/assets.ts b/merged-packages/tron-wallet-snap/src/entities/assets.ts index 582cddb4..8cbca314 100644 --- a/merged-packages/tron-wallet-snap/src/entities/assets.ts +++ b/merged-packages/tron-wallet-snap/src/entities/assets.ts @@ -13,6 +13,8 @@ type BaseAsset = { decimals: number; rawAmount: string; // Without decimals uiAmount: string; // With decimals + iconUrl: string; // Asset icon URL + imageSvg?: string; // Converted SVG for Snaps UI display }; export type NativeAsset = BaseAsset & { diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts index 7baffb9f..d0d72324 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/assets.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -30,13 +30,19 @@ export class AssetsHandler { } async onAssetHistoricalPrice( - _params: OnAssetHistoricalPriceArguments, + params: OnAssetHistoricalPriceArguments, ): Promise { + this.#logger.log('[📈 onAssetHistoricalPrice]', params); + + const { from, to } = params; + + const historicalPrice = await this.#assetsService.getHistoricalPrice( + from, + to, + ); + return { - historicalPrice: { - intervals: {}, - updateTime: Date.now(), - }, + historicalPrice, }; } diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 910e5999..943390e5 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -1,19 +1,39 @@ import { TransactionStatus } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; -import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; +import { + InvalidParamsError, + MethodNotFoundError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; import { assert } from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { sha256 } from 'ethers'; +import { ClientRequestMethod, SendErrorCodes } from './types'; +import { + ComputeFeeRequestStruct, + ComputeFeeResponseStruct, + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, + OnConfirmSendRequestStruct, + OnConfirmStakeRequestStruct, + OnConfirmUnstakeRequestStruct, + OnStakeAmountInputRequestStruct, + OnUnstakeAmountInputRequestStruct, + SignAndSendTransactionRequestStruct, +} from './validation'; import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import { Networks } from '../../constants'; +import type { Network } from '../../constants'; import type { AccountsService } from '../../services/accounts/AccountsService'; import type { AssetsService } from '../../services/assets/AssetsService'; import type { NativeCaipAssetType, StakedCaipAssetType, } from '../../services/assets/types'; +import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; import type { FeeCalculatorService } from '../../services/send/FeeCalculatorService'; import type { SendService } from '../../services/send/SendService'; import type { StakingService } from '../../services/staking/StakingService'; @@ -21,19 +41,6 @@ import { assertOrThrow } from '../../utils/assertOrThrow'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import { BackgroundEventMethod } from '../cronjob'; -import { ClientRequestMethod, SendErrorCodes } from './types'; -import { - ComputeFeeRequestStruct, - ComputeFeeResponseStruct, - OnAddressInputRequestStruct, - OnAmountInputRequestStruct, - OnConfirmSendRequestStruct, - OnConfirmStakeRequestStruct, - OnConfirmUnstakeRequestStruct, - OnStakeAmountInputRequestStruct, - OnUnstakeAmountInputRequestStruct, - SignAndSendTransactionRequestStruct, -} from './validation'; export class ClientRequestHandler { readonly #logger: ILogger; @@ -52,6 +59,8 @@ export class ClientRequestHandler { readonly #stakingService: StakingService; + readonly #confirmationHandler: ConfirmationHandler; + constructor({ logger, accountsService, @@ -61,6 +70,7 @@ export class ClientRequestHandler { tronWebFactory, snapClient, stakingService, + confirmationHandler, }: { logger: ILogger; accountsService: AccountsService; @@ -70,6 +80,7 @@ export class ClientRequestHandler { tronWebFactory: TronWebFactory; snapClient: SnapClient; stakingService: StakingService; + confirmationHandler: ConfirmationHandler; }) { this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); this.#accountsService = accountsService; @@ -79,6 +90,7 @@ export class ClientRequestHandler { this.#tronWebFactory = tronWebFactory; this.#snapClient = snapClient; this.#stakingService = stakingService; + this.#confirmationHandler = confirmationHandler; } /** @@ -288,6 +300,15 @@ export class ClientRequestHandler { const { fromAccountId, toAddress, amount, assetId } = request.params; + const account = await this.#accountsService.findById(fromAccountId); + + if (!account) { + return { + valid: false, + errors: [SendErrorCodes.Invalid], + }; + } + const asset = await this.#assetsService.getAssetByAccountId( fromAccountId, assetId, @@ -300,15 +321,76 @@ export class ClientRequestHandler { }; } - const transaction = await this.#sendService.sendAsset({ + const { chainId } = parseCaipAssetType(assetId); + const scope = chainId as Network; + + /** + * Get available Energy and Bandwidth from account assets. + */ + const assetsPromise = this.#assetsService.getAssetsByAccountId( + fromAccountId, + [Networks[scope].bandwidth.id, Networks[scope].energy.id], + ); + + /** + * Build the transaction that will be sent + */ + const transactionPromise = this.#sendService.buildTransaction({ fromAccountId, toAddress, asset, amount: BigNumber(amount).toNumber(), }); + // wait for both + const [[bandwidthAsset, energyAsset], transaction] = await Promise.all([ + assetsPromise, + transactionPromise, + ]); + + const availableEnergy = energyAsset + ? BigNumber(energyAsset.rawAmount) + : BigNumber(0); + const availableBandwidth = bandwidthAsset + ? BigNumber(bandwidthAsset.rawAmount) + : BigNumber(0); + + const fees = await this.#feeCalculatorService.computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + }); + + /** + * Show the confirmation UI + */ + const confirmed = await this.#confirmationHandler.confirmTransactionRequest( + { + scope, + fromAddress: account.address, + toAddress, + amount, + fees, + asset, + }, + ); + + if (!confirmed) { + throw new UserRejectedRequestError() as unknown as Error; + } + + /** + * Sign and send the built transaction + */ + const result = await this.#sendService.signAndSendTransaction({ + scope, + fromAccountId, + transaction, + }); + return { - transactionId: transaction.txId, + transactionId: result.txid, status: TransactionStatus.Submitted, }; } @@ -407,30 +489,18 @@ export class ClientRequestHandler { ); const { accountId, assetId, value } = request.params; - console.log( - `[debug] [handleOnStakeAmountInput] All params`, - JSON.stringify({ accountId, assetId, value }), - ); await this.#accountsService.findByIdOrThrow(accountId); const asset = await this.#assetsService.getAssetByAccountId( accountId, assetId, ); - console.log( - `[debug] [handleOnStakeAmountInput] Asset`, - JSON.stringify(asset), - ); /** * If the account doesn't have this asset, treat it as having zero balance */ const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); - console.log( - `[debug] [handleOnStakeAmountInput] Account balance`, - JSON.stringify({ accountBalance, requestBalance }), - ); if (requestBalance.isGreaterThan(accountBalance)) { return { @@ -465,29 +535,16 @@ export class ClientRequestHandler { value, options: { purpose }, } = request.params; - console.log( - `[debug] [handleConfirmStake] All params`, - JSON.stringify({ fromAccountId, assetId, value, purpose }), - ); const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - console.log( - `[debug] [handleConfirmStake] Account`, - JSON.stringify(account), - ); const asset = await this.#assetsService.getAssetByAccountId( fromAccountId, assetId, ); - console.log(`[debug] [handleConfirmStake] Asset`, JSON.stringify(asset)); const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); - console.log( - `[debug] [handleConfirmStake] Account balance`, - JSON.stringify({ accountBalance, requestBalance }), - ); /** * Check if account has enough of the asset... */ @@ -507,7 +564,6 @@ export class ClientRequestHandler { amount: requestBalance, purpose, }); - console.log(`[debug] [handleConfirmStake] Stake worked`); return { valid: true, @@ -535,10 +591,6 @@ export class ClientRequestHandler { value, options: { purpose }, } = request.params; - console.log( - `[debug] [handleOnUnstakeAmountInput] All params`, - JSON.stringify({ accountId, assetId, value, purpose }), - ); /** * We convert the `slip44:195` to `slip44:195-staked-for-bandwidth` or `slip44:195-staked-for-energy` @@ -551,17 +603,9 @@ export class ClientRequestHandler { accountId, stakedAssetId, ); - console.log( - `[debug] [handleOnUnstakeAmountInput] Asset`, - JSON.stringify(asset), - ); const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); - console.log( - `[debug] [handleOnUnstakeAmountInput] Account balance`, - JSON.stringify({ accountBalance, requestBalance }), - ); /** * Check if account has enough of the asset... @@ -599,10 +643,6 @@ export class ClientRequestHandler { value, options: { purpose }, } = request.params; - console.log( - `[debug] [handleConfirmUnstake] All params`, - JSON.stringify({ accountId, assetId, value, purpose }), - ); /** * We convert the `slip44:195-staked-for-bandwidth` or `slip44:195-staked-for-energy` to `slip44:195` @@ -610,29 +650,16 @@ export class ClientRequestHandler { */ const stakedAssetId = `${assetId}-staked-for-${purpose.toLowerCase()}` as StakedCaipAssetType; - console.log( - `[debug] [handleConfirmUnstake] Staked asset ID`, - JSON.stringify(stakedAssetId), - ); const account = await this.#accountsService.findByIdOrThrow(accountId); - console.log( - `[debug] [handleConfirmUnstake] Account`, - JSON.stringify(account), - ); const asset = await this.#assetsService.getAssetByAccountId( accountId, stakedAssetId, ); - console.log(`[debug] [handleConfirmUnstake] Asset`, JSON.stringify(asset)); const accountBalance = asset ? BigNumber(asset.uiAmount) : BigNumber(0); const requestBalance = BigNumber(value); - console.log( - `[debug] [handleConfirmUnstake] Account balance`, - JSON.stringify({ accountBalance, requestBalance }), - ); /** * Check if account has enough of the asset... @@ -652,7 +679,6 @@ export class ClientRequestHandler { assetId: stakedAssetId, amount: requestBalance, }); - console.log(`[debug] [handleConfirmUnstake] Unstake worked`); return { valid: true, diff --git a/merged-packages/tron-wallet-snap/src/handlers/userInput.ts b/merged-packages/tron-wallet-snap/src/handlers/userInput.ts index 991da43d..9ddd725c 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/userInput.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/userInput.ts @@ -1,18 +1,66 @@ import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; +import type { SnapClient } from '../clients/snap/SnapClient'; +import { createEventHandlers as createTransactionConfirmationEvents } from '../ui/confirmation/views/ConfirmTransactionRequest/events'; +import { withCatchAndThrowSnapError } from '../utils/errors'; +import { createPrefixedLogger, type ILogger } from '../utils/logger'; + export class UserInputHandler { + readonly #logger: ILogger; + + readonly #snapClient: SnapClient; + + constructor({ + logger, + snapClient, + }: { + logger: ILogger; + snapClient: SnapClient; + }) { + this.#logger = createPrefixedLogger(logger, '[👵 LifecycleHandler]'); + this.#snapClient = snapClient; + } + + /** + * Handle user events requests. + * + * @param args - The request handler args as object. + * @param args.id - The interface id associated with the event. + * @param args.event - The event object. + * @param args.context - The context object. + * @returns A promise that resolves to a JSON object. + * @throws If the request method is not valid for this snap. + */ async handle({ - id: _id, - event: _event, - context: _context, + id, + event, + context, }: { id: string; event: UserInputEvent; context: InterfaceContext | null; }): Promise { + this.#logger.log('[👇 onUserInput]', id, event); + + if (!event.name) { + return; + } + + const uiEventHandlers: Record Promise> = { + ...createTransactionConfirmationEvents(this.#snapClient), + }; + /** - * Map user input to the appropriate handler + * Using the name of the event, route it to the correct handler */ - // TODO: No user input yet + const handler = uiEventHandlers[event.name]; + + if (!handler) { + return; + } + + await withCatchAndThrowSnapError(async () => + handler({ id, event, context }), + ); } } diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 70986a61..cd8de06d 100644 --- a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -10,9 +10,11 @@ import type { AssetMetadata, FungibleAssetMarketData, FungibleAssetMetadata, + HistoricalPriceIntervals, } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; import type { CaipAssetType } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { pick } from 'lodash'; @@ -26,6 +28,10 @@ import type { } from './types'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { FiatTicker, SpotPrice } from '../../clients/price-api/types'; +import { + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + VsCurrencyParamStruct, +} from '../../clients/price-api/types'; import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { AccountResources } from '../../clients/tron-http'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; @@ -158,6 +164,7 @@ export class AssetsService { keyringAccountId: account.id, network: scope, rawAmount: '0', + iconUrl: Networks[scope].nativeToken.iconUrl, }, ]; } @@ -215,8 +222,14 @@ export class AssetsService { asset.assetType ] as FungibleAssetMetadata | null; - let { symbol } = asset; - let decimals = asset.decimals ?? 0; + const { + symbol: initialSymbol, + decimals: initialDecimals = 0, + iconUrl: initialIconUrl, + } = asset; + let symbol = initialSymbol; + let decimals = initialDecimals; + let iconUrl = initialIconUrl; if (metadata?.fungible) { const unit = metadata.units?.[0]; @@ -226,6 +239,8 @@ export class AssetsService { } else { symbol = metadata?.symbol ?? symbol; } + // Include iconUrl from metadata if available + iconUrl = metadata.iconUrl ?? iconUrl; } const uiAmount = new BigNumber(asset.rawAmount) @@ -237,6 +252,7 @@ export class AssetsService { symbol, decimals, uiAmount, + iconUrl, }; }); @@ -262,6 +278,7 @@ export class AssetsService { uiAmount: new BigNumber(tronAccountInfo.balance) .dividedBy(10 ** Networks[scope].nativeToken.decimals) .toString(), + iconUrl: Networks[scope].nativeToken.iconUrl, }; return asset; @@ -313,6 +330,7 @@ export class AssetsService { uiAmount: new BigNumber(stakedBandwidthAmount) .dividedBy(10 ** Networks[scope].stakedForBandwidth.decimals) .toString(), + iconUrl: Networks[scope].stakedForBandwidth.iconUrl, }; assets.push(stakedBandwidthAsset); } @@ -328,6 +346,7 @@ export class AssetsService { uiAmount: new BigNumber(stakedEnergyAmount) .dividedBy(10 ** Networks[scope].stakedForEnergy.decimals) .toString(), + iconUrl: Networks[scope].stakedForEnergy.iconUrl, }; assets.push(stakedEnergyAsset); } @@ -359,6 +378,7 @@ export class AssetsService { decimals: Networks[scope].bandwidth.decimals, rawAmount: bandwidth.toString(), uiAmount: bandwidth.toString(), + iconUrl: Networks[scope].bandwidth.iconUrl, }, { assetType: Networks[scope].maximumBandwidth.id, @@ -368,6 +388,7 @@ export class AssetsService { decimals: Networks[scope].maximumBandwidth.decimals, rawAmount: maximumBandwidth.toString(), uiAmount: maximumBandwidth.toString(), + iconUrl: Networks[scope].maximumBandwidth.iconUrl, }, ]; } @@ -393,6 +414,7 @@ export class AssetsService { decimals: Networks[scope].energy.decimals, rawAmount: energy.toString(), uiAmount: energy.toString(), + iconUrl: Networks[scope].energy.iconUrl, }, { assetType: Networks[scope].maximumEnergy.id, @@ -402,6 +424,7 @@ export class AssetsService { decimals: Networks[scope].maximumEnergy.decimals, rawAmount: maximumEnergy.toString(), uiAmount: maximumEnergy.toString(), + iconUrl: Networks[scope].maximumEnergy.iconUrl, }, ]; } @@ -429,6 +452,7 @@ export class AssetsService { decimals: 0, rawAmount: tokenObject.value?.toString() ?? '0', uiAmount: '0', + iconUrl: '', // Will be enriched with metadata later }; }) ?? []; @@ -457,6 +481,7 @@ export class AssetsService { decimals: 0, rawAmount: balance, uiAmount: '0', + iconUrl: '', // Will be enriched with metadata later }; }); }) ?? []; @@ -1252,4 +1277,79 @@ export class AssetsService { return result; } + + /** + * Get historical prices for a token pair by calling the Price API. + * Similar to the Solana snap implementation. + * + * @param from - The asset to get historical prices for. + * @param to - The currency to convert prices to. + * @returns Historical price data with intervals. + */ + async getHistoricalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise<{ + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }> { + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); + assert(toTicker, VsCurrencyParamStruct); + + const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; + + // For each time period, call the Price API to fetch the historical prices + const promises = timePeriodsToFetch.map(async (timePeriod) => + this.#priceApiClient + .getHistoricalPrices({ + assetType: from, + timePeriod, + vsCurrency: toTicker, + }) + // Wrap the response in an object with the time period and the response for easier reducing + .then((response) => ({ + timePeriod, + response, + })) + // Gracefully handle individual errors to avoid breaking the entire operation + .catch((error) => { + this.#logger.warn( + `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, + error, + ); + return { + timePeriod, + response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + }; + }), + ); + + const wrappedHistoricalPrices = await Promise.all(promises); + + const intervals = wrappedHistoricalPrices.reduce( + (acc, { timePeriod, response }) => { + const iso8601Interval = `P${timePeriod.toUpperCase()}`; + acc[iso8601Interval] = response.prices.map((price) => [ + price[0], + price[1].toString(), + ]); + return acc; + }, + {}, + ); + + const now = Date.now(); + + const result = { + intervals, + updateTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + + return result; + } } diff --git a/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts new file mode 100644 index 00000000..ce1875fd --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts @@ -0,0 +1,41 @@ +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { Network } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import { render as renderConfirmTransactionRequest } from '../../ui/confirmation/views/ConfirmTransactionRequest/render'; +import type { ComputeFeeResult } from '../send/types'; + +export class ConfirmationHandler { + readonly #snapClient: SnapClient; + + constructor({ snapClient }: { snapClient: SnapClient }) { + this.#snapClient = snapClient; + } + + async confirmTransactionRequest({ + scope, + fromAddress, + toAddress, + amount, + fees, + asset, + }: { + scope: Network; + fromAddress: string; + toAddress: string; + amount: string; + fees: ComputeFeeResult; + asset: AssetEntity; + }): Promise { + const result = await renderConfirmTransactionRequest(this.#snapClient, { + scope, + fromAddress, + toAddress, + amount, + fees, + asset, + origin: 'MetaMask', + }); + + return result === true; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts index bf1fd493..006b0085 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts @@ -129,6 +129,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:bandwidth', amount: '758000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -155,6 +157,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:195', amount: '758.000000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -193,6 +197,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:energy', amount: '55001', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, { @@ -202,6 +208,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:bandwidth', amount: '1083000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -229,6 +237,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:energy', amount: '30000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, { @@ -238,6 +248,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:bandwidth', amount: '1083000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, { @@ -247,6 +259,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:195', amount: '2.500100', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -274,6 +288,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:energy', amount: '55001', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, { @@ -283,6 +299,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:195', amount: '2947.000000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -311,6 +329,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:energy', amount: '30000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, { @@ -320,6 +340,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:195', amount: '2949.500100', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -348,6 +370,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:195', amount: '758.000000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -373,6 +397,8 @@ describe('FeeCalculatorService', () => { type: 'tron:2494104990/slip44:bandwidth', amount: '758000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -408,6 +434,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:energy', amount: '100000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, { @@ -417,6 +445,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:bandwidth', amount: '1083000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, { @@ -426,6 +456,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:195', amount: '6.500000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -461,6 +493,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:energy', amount: '55001', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, { @@ -470,6 +504,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:195', amount: '10947.000000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); @@ -495,6 +531,8 @@ describe('FeeCalculatorService', () => { type: 'tron:728126428/slip44:bandwidth', amount: '758000', fungible: true, + imageSvg: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', }, }, ]); diff --git a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts index a40b4622..dbcf2e83 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts @@ -11,6 +11,7 @@ import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import type { Network } from '../../constants'; import { Networks } from '../../constants'; +import { getIconUrlForKnownAsset } from '../../ui/confirmation/utils/getIconUrlForKnownAsset'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; @@ -252,6 +253,7 @@ export class FeeCalculatorService { type: Networks[scope].energy.id, amount: energyConsumed.toString(), fungible: true as const, + imageSvg: getIconUrlForKnownAsset(Networks[scope].energy.id) ?? '', }, }, { @@ -261,6 +263,7 @@ export class FeeCalculatorService { type: Networks[scope].bandwidth.id, amount: bandwidthConsumed.toString(), fungible: true as const, + imageSvg: getIconUrlForKnownAsset(Networks[scope].bandwidth.id) ?? '', }, }, ]; @@ -296,6 +299,8 @@ export class FeeCalculatorService { type: Networks[scope].nativeToken.id, amount: totalCostTRX.toFixed(6), fungible: true as const, + imageSvg: + getIconUrlForKnownAsset(Networks[scope].nativeToken.id) ?? '', }, }); } diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts index a340625a..f662b290 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -1,4 +1,11 @@ import { parseCaipAssetType } from '@metamask/utils'; +import type { + BroadcastReturn, + Transaction, + TransferAssetContract, + TransferContract, + TriggerSmartContract, +} from 'tronweb/lib/esm/types'; import type { TransactionResult } from './types'; import type { SnapClient } from '../../clients/snap/SnapClient'; @@ -35,6 +42,196 @@ export class SendService { this.#snapClient = snapClient; } + async buildTransaction({ + fromAccountId, + toAddress, + asset, + amount, + }: { + fromAccountId: string; + toAddress: string; + asset: AssetEntity; + amount: number; + }): Promise { + const { chainId, assetNamespace, assetReference } = parseCaipAssetType( + asset.assetType, + ); + + try { + switch (assetNamespace) { + case 'slip44': + this.#logger.log('Sending TRX transaction'); + return this.buildSendTrxTransaction({ + scope: chainId as Network, + fromAccountId, + toAddress, + amount, + }); + + case 'trc10': + this.#logger.log(`Sending TRC10 token: ${assetReference}`); + return this.buildSendTrc10Transaction({ + scope: chainId as Network, + fromAccountId, + toAddress, + amount, + tokenId: assetReference, + }); + + case 'trc20': + this.#logger.log(`Sending TRC20 token: ${assetReference}`); + return this.buildSendTrc20Transaction({ + scope: chainId as Network, + fromAccountId, + toAddress, + contractAddress: assetReference, + amount, + decimals: asset.decimals, + }); + + default: + throw new Error(`Unsupported asset namespace: ${assetNamespace}`); + } + } catch (error) { + this.#logger.error({ error }, 'Failed to send asset'); + throw new Error( + `Failed to send asset: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + async buildSendTrxTransaction({ + scope, + fromAccountId, + toAddress, + amount, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + amount: number; + }): Promise> { + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + const amountInSun = amount * 1e6; // Convert TRX to sun + const transaction = await tronWeb.transactionBuilder.sendTrx( + toAddress, + amountInSun, + ); + + return transaction; + } + + async buildSendTrc10Transaction({ + scope, + fromAccountId, + toAddress, + amount, + tokenId, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + amount: number; + tokenId: string; + }): Promise> { + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + const transaction = await tronWeb.transactionBuilder.sendToken( + toAddress, + amount, + tokenId, + ); + return transaction; + } + + async buildSendTrc20Transaction({ + scope, + fromAccountId, + toAddress, + contractAddress, + amount, + decimals, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + contractAddress: string; + amount: number; + decimals: number; + }): Promise> { + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + const functionSelector = 'transfer(address,uint256)'; + const decimalsAdjustedAmount = amount * 10 ** decimals; + const parameter = [ + { type: 'address', value: toAddress }, + { type: 'uint256', value: decimalsAdjustedAmount }, + ]; + + const contractResult = + await tronWeb.transactionBuilder.triggerSmartContract( + contractAddress, + functionSelector, + {}, + parameter, + ); + + return contractResult.transaction; + } + + async signAndSendTransaction({ + scope, + fromAccountId, + transaction, + }: { + scope: Network; + fromAccountId: string; + transaction: + | Transaction + | Transaction + | Transaction; + }): Promise> { + /** + * Initialize TronWeb client with the account's private key + */ + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + /** + * Sign and send the transaction + */ + const signedTx = await tronWeb.trx.sign(transaction); + const result = await tronWeb.trx.sendRawTransaction(signedTx); + + return result; + } + async sendAsset({ fromAccountId, toAddress, diff --git a/merged-packages/tron-wallet-snap/src/services/send/types.ts b/merged-packages/tron-wallet-snap/src/services/send/types.ts index b2d8d5ad..6f59bb67 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/types.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/types.ts @@ -6,12 +6,15 @@ export type TransactionResult = { transaction: any; }; +export type FeeAsset = { + unit: string; + type: string; + amount: string; + fungible: true; + imageSvg: string; +}; + export type ComputeFeeResult = { type: FeeType; - asset: { - unit: string; - type: string; - amount: string; - fungible: true; - }; + asset: FeeAsset; }[]; diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts index 165b098a..9e1d9a3c 100644 --- a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts @@ -52,10 +52,6 @@ export class StakingService { this.#logger.info( `Staking ${amount.toString()} ${assetId} from ${account.address} for ${purpose}...`, ); - console.log( - `[debug] [stake] All params`, - JSON.stringify({ account, assetId, amount, purpose }), - ); const { chainId } = parseCaipAssetType(assetId); @@ -75,13 +71,10 @@ export class StakingService { purpose, account.address, ); - console.log(`[debug] [stake] Transaction`, JSON.stringify(transaction)); const signedTx = await tronWeb.trx.sign(transaction); - console.log(`[debug] [stake] Transaction`, JSON.stringify(transaction)); - const result = await tronWeb.trx.sendRawTransaction(signedTx); - console.log(`[debug] [stake] Result`, JSON.stringify(result)); + await tronWeb.trx.sendRawTransaction(signedTx); /** * Sync account after the transaction happens @@ -105,10 +98,6 @@ export class StakingService { this.#logger.info( `Unstaking ${amount.toString()} ${assetId} from ${account.address}...`, ); - console.log( - `[debug] [unstake] All params`, - JSON.stringify({ account, assetId, amount }), - ); const { chainId } = parseCaipAssetType(assetId); @@ -147,8 +136,6 @@ export class StakingService { purpose = 'ENERGY'; } - console.log(`[debug] [unstake] Purpose`, JSON.stringify(purpose)); - if (!purpose) { throw new Error('Invalid asset ID'); } @@ -159,16 +146,10 @@ export class StakingService { purpose, account.address, ); - console.log(`[debug] [unstake] Transaction`, JSON.stringify(transaction)); const signedTx = await tronWeb.trx.sign(transaction); - console.log( - `[debug] [unstake] Signed transaction`, - JSON.stringify(signedTx), - ); - const result = await tronWeb.trx.sendRawTransaction(signedTx); - console.log(`[debug] [unstake] Result`, JSON.stringify(result)); + await tronWeb.trx.sendRawTransaction(signedTx); /** * Sync account after the transaction happens diff --git a/merged-packages/tron-wallet-snap/src/static/tron-logo.ts b/merged-packages/tron-wallet-snap/src/static/tron-logo.ts new file mode 100644 index 00000000..12d58b73 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/static/tron-logo.ts @@ -0,0 +1 @@ +export const TRX_IMAGE_SVG = `tron`; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx new file mode 100644 index 00000000..bea90a2f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx @@ -0,0 +1,28 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Box, Image, Text as SnapText } from '@metamask/snaps-sdk/jsx'; + +type AssetProps = { + symbol: string; + amount: string; + iconSvg: string; + showAmount?: boolean; +}; + +/** + * Asset component for displaying assets with optional icon and amount. + * Pure component with no business logic - just visual display. + * + * @param props - The props for the asset component. + * @returns The rendered asset element. + */ +export const Asset = (props: AssetProps): ComponentOrElement => { + const { symbol, amount, iconSvg } = props; + return ( + + + + + {`${amount} ${symbol}`} + + ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx new file mode 100644 index 00000000..3b8993a6 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx @@ -0,0 +1,57 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Box, Text as SnapText } from '@metamask/snaps-sdk/jsx'; + +import { Asset } from './Asset/Asset'; +import type { ComputeFeeResult } from '../../../services/send/types'; +import type { Preferences } from '../../../types/snap'; +import { i18n } from '../../../utils/i18n'; + +type FeesProps = { + fees: ComputeFeeResult; + preferences: Preferences; +}; + +export const Fees = ({ fees, preferences }: FeesProps): ComponentOrElement => { + const translate = i18n(preferences.locale); + + /** + * Make sure the TRX is shown first for cases where both + * TRX and a resource are used. + */ + const sortedFees = [...fees].sort((feeA, feeB) => { + const isTrxA = feeA.asset.unit === 'TRX'; + const isTrxB = feeB.asset.unit === 'TRX'; + + if (isTrxA && !isTrxB) return -1; + if (!isTrxA && isTrxB) return 1; + return 0; + }); + + return ( + + {sortedFees.map((feeItem, index) => ( + + {/* Left side - show text only for first item (native TRX) */} + {index === 0 ? ( + + {translate('confirmation.transactionFee')} + + ) : ( + {null} + )} + + {/* Right side - fee value with asset display */} + + + ))} + + ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/utils/getIconUrlForKnownAsset.ts b/merged-packages/tron-wallet-snap/src/ui/confirmation/utils/getIconUrlForKnownAsset.ts new file mode 100644 index 00000000..1b8307b0 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/utils/getIconUrlForKnownAsset.ts @@ -0,0 +1,17 @@ +import { TokenMetadata } from '../../../constants'; + +/** + * Resolves iconUrl for a given asset type. + * This should only be called with the asset types that are in TokenMetadata constants. + * + * @param assetType - The CAIP-19 asset type to resolve. + * @returns The icon URL if known, otherwise undefined. + */ +export const getIconUrlForKnownAsset = ( + assetType: string, +): string | undefined => { + if (assetType in TokenMetadata) { + return TokenMetadata[assetType as keyof typeof TokenMetadata].iconUrl; + } + return undefined; +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx new file mode 100644 index 00000000..3ebc3ca3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx @@ -0,0 +1,143 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Section, + Text as SnapText, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; + +import { ConfirmSignAndSendTransactionFormNames } from './events'; +import { type ConfirmTransactionRequestContext } from './types'; +import { Networks } from '../../../../constants'; +import { i18n } from '../../../../utils/i18n'; +import { Asset } from '../../components/Asset/Asset'; +import { Fees } from '../../components/Fees'; + +export const ConfirmTransactionRequest = ({ + context: { + origin, + scope, + fromAddress, + toAddress, + asset, + amount, + fees, + preferences, + networkImage, + }, +}: { + context: ConfirmTransactionRequestContext; +}): ComponentOrElement => { + const translate = i18n(preferences.locale); + + return ( + + + {/* Header */} + + {null} + + {translate(`confirmation.transaction.title`)} + + {null} + + {/* Estimated Changes */} +
+ {/* Header + Tooltip */} + + + {translate('confirmation.estimatedChanges.title')} + + + + + + + + + {translate('confirmation.estimatedChanges.send')} + + + + +
+ + {/* Additional Details */} +
+ {/* Request from */} + + + + {translate('confirmation.origin')} + + + + + + {origin} + + {null} + {/* Account */} + + + {translate('confirmation.account')} + +
+ + {null} + {/* Recipient */} + {toAddress && ( + + + {translate('confirmation.recipient')} + +
+ + )} + {null} + {/* Network */} + + + {translate('confirmation.network')} + + + + + + {Networks[scope].name} + + + {null} + {/* Fee Breakdown */} + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/events.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/events.tsx new file mode 100644 index 00000000..1ffcf838 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/events.tsx @@ -0,0 +1,55 @@ +import type { SnapClient } from '../../../../clients/snap/SnapClient'; + +/** + * Handle cancel button click by resolving the interface with a falsy value. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param options - The options bag. + * @param options.id - The interface id. + */ +async function onCancelButtonClick( + snapClient: SnapClient, + options: { id: string }, +): Promise { + const { id } = options; + await snapClient.resolveInterface(id, false); +} + +/** + * Handle confirm button click by resolving the interface with a truthy value. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param options - The options bag. + * @param options.id - The interface id. + */ +async function onConfirmButtonClick( + snapClient: SnapClient, + options: { id: string }, +): Promise { + const { id } = options; + await snapClient.resolveInterface(id, true); +} + +export enum ConfirmSignAndSendTransactionFormNames { + Cancel = 'confirm-sign-and-send-transaction-cancel', + Confirm = 'confirm-sign-and-send-transaction-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @param snapClient - The SnapClient instance for API interactions. + * @returns Object containing event handlers. + */ +export function createEventHandlers( + snapClient: SnapClient, +): Record Promise> { + return { + [ConfirmSignAndSendTransactionFormNames.Cancel]: async (options: { + id: string; + }) => onCancelButtonClick(snapClient, options), + [ConfirmSignAndSendTransactionFormNames.Confirm]: async (options: { + id: string; + }) => onConfirmButtonClick(snapClient, options), + }; +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx new file mode 100644 index 00000000..06f5a2e3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx @@ -0,0 +1,120 @@ +import type { DialogResult } from '@metamask/snaps-sdk'; + +import { ConfirmTransactionRequest } from './ConfirmTransactionRequest'; +import type { ConfirmTransactionRequestContext } from './types'; +import type { SnapClient } from '../../../../clients/snap/SnapClient'; +import { Network } from '../../../../constants'; +import type { AssetEntity } from '../../../../entities/assets'; +import type { ComputeFeeResult } from '../../../../services/send/types'; +import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; +import { generateImageComponent } from '../../../utils/generateImageComponent'; +import { getIconUrlForKnownAsset } from '../../utils/getIconUrlForKnownAsset'; + +export const DEFAULT_CONFIRMATION_CONTEXT: ConfirmTransactionRequestContext = { + scope: Network.Mainnet, + fromAddress: null, + toAddress: null, + amount: null, + fees: [], + asset: { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: '', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + imageSvg: TRX_IMAGE_SVG, + }, + origin: 'MetaMask', + networkImage: TRX_IMAGE_SVG, + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: false, + useExternalPricingData: true, + simulateOnChainActions: false, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }, +}; + +/** + * Render the ConfirmTransactionRequest UI and show a dialog resolving to the user's choice. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param incomingContext - The initial context for the confirmation view. + * @param incomingContext.scope - The network scope for the transaction. + * @param incomingContext.fromAddress - The sender address. + * @param incomingContext.toAddress - The recipient address. + * @param incomingContext.amount - The amount to send (as a string). + * @param incomingContext.fees - The detailed fee breakdown array. + * @param incomingContext.asset - The asset involved in the transaction. + * @param incomingContext.origin - The origin string to display. + * @returns A dialog result with the user's decision. + */ +export async function render( + snapClient: SnapClient, + incomingContext: { + scope: Network; + fromAddress: string; + toAddress: string; + amount: string; + fees: ComputeFeeResult; + asset: AssetEntity; + origin: string; + }, +): Promise { + const context: ConfirmTransactionRequestContext = { + ...DEFAULT_CONFIRMATION_CONTEXT, + ...incomingContext, + }; + + console.log( + 'RENDERING CONFIRM TRANSACTION REQUEST WITH CONTEXT', + JSON.stringify(context), + ); + + try { + context.preferences = await snapClient.getPreferences(); + } catch { + // keep defaults + } + + /** + * Generate SVG images for all assets (it's an async process so it must be done here). + */ + const [assetSvg, ...feeSvgs] = await Promise.all([ + generateImageComponent(context.asset.iconUrl, 16, 16), + ...context.fees.map(async (fee) => { + return await generateImageComponent( + getIconUrlForKnownAsset(fee.asset.type), + 16, + 16, + ); + }), + ]); + + /** + * There will always be SVGs for the assets because we fallback to the question mark SVG. + */ + context.asset = { + ...context.asset, + imageSvg: assetSvg, + }; + context.fees.forEach((fee, index) => { + fee.asset.imageSvg = feeSvgs[index] ?? ''; + }); + + const id = await snapClient.createInterface( + , + context, + ); + const dialogPromise = snapClient.showDialog(id); + + return dialogPromise; +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts new file mode 100644 index 00000000..0a635915 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts @@ -0,0 +1,16 @@ +import type { Network } from '../../../../constants'; +import type { AssetEntity } from '../../../../entities/assets'; +import type { ComputeFeeResult } from '../../../../services/send/types'; +import type { Preferences } from '../../../../types/snap'; + +export type ConfirmTransactionRequestContext = { + origin: string; + scope: Network; + fromAddress: string | null; + toAddress: string | null; + amount: string | null; + fees: ComputeFeeResult; + asset: AssetEntity; + preferences: Preferences; + networkImage: string; +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/utils/generateImageComponent.ts b/merged-packages/tron-wallet-snap/src/ui/utils/generateImageComponent.ts new file mode 100644 index 00000000..3654d209 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/utils/generateImageComponent.ts @@ -0,0 +1,28 @@ +import { getImageComponent } from '@metamask/snaps-sdk'; + +import questionMarkSvg from '../../../images/question-mark.svg'; + +/** + * Generate an SVG image component for a given image URL, with fallback. + * + * @param imageUrl - The image URL to render. + * @param width - The desired width. + * @param height - The desired height. + * @returns A promise resolving to an SVG string. + */ +export async function generateImageComponent( + imageUrl?: string, + width = 48, + height = 48, +): Promise { + if (!imageUrl) { + return questionMarkSvg; + } + + try { + const image = await getImageComponent(imageUrl, { width, height }); + return image.value; + } catch { + return questionMarkSvg; + } +} diff --git a/merged-packages/tron-wallet-snap/src/utils/errors.ts b/merged-packages/tron-wallet-snap/src/utils/errors.ts index e0f14628..9546328f 100644 --- a/merged-packages/tron-wallet-snap/src/utils/errors.ts +++ b/merged-packages/tron-wallet-snap/src/utils/errors.ts @@ -64,10 +64,6 @@ export const withCatchAndThrowSnapError = async ( { error }, `[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`, ); - console.log( - `[debug] [withCatchAndThrowSnapError] Error`, - JSON.stringify(error), - ); throw error; } From ffd2d73ec4b769be41c4bc22b9305957a3bf763a Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Fri, 14 Nov 2025 13:16:48 +0100 Subject: [PATCH 079/238] chore: remove logs (#87) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/tron-http/TronHttpClient.ts | 26 +++++++-- .../tron-wallet-snap/src/context.ts | 1 + .../transactions/TransactionsMapper.test.ts | 47 ---------------- .../transactions/TransactionsService.test.ts | 55 +------------------ .../ConfirmTransactionRequest/render.tsx | 5 -- 6 files changed, 27 insertions(+), 109 deletions(-) diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index ad8912da..fea27391 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "HQaGe9G9dM2fdhvG9miTkwWahXBNxZztxneiLX4XaQ0=", + "shasum": "kBGVxeHulndPTqH8yULyd5hyMy041vs1uvUJxtgob18=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts index 69bb5fa1..902ecd48 100644 --- a/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts @@ -10,12 +10,15 @@ import type { import type { Network } from '../../constants'; import { NULL_ADDRESS } from '../../constants'; import type { ConfigProvider } from '../../services/config'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; /** * Client for Tron JSON-RPC HTTP endpoints (not the REST API) * Handles contract interactions, constant contract calls, etc. */ export class TronHttpClient { + readonly #logger: ILogger; + readonly #clients: Map< Network, { @@ -24,7 +27,14 @@ export class TronHttpClient { } > = new Map(); - constructor({ configProvider }: { configProvider: ConfigProvider }) { + constructor({ + configProvider, + logger, + }: { + configProvider: ConfigProvider; + logger: ILogger; + }) { + this.#logger = createPrefixedLogger(logger, '[🌐 TronHttpClient]'); const { baseUrls } = configProvider.get().tronHttpApi; // Initialize clients for all networks @@ -170,7 +180,12 @@ export class TronHttpClient { return result.trim(); } catch (error) { - console.error('Error decoding hex string:', error, 'hex:', hexString); + this.#logger.error( + 'Error decoding hex string:', + error, + 'hex:', + hexString, + ); return ''; } } @@ -295,7 +310,10 @@ export class TronHttpClient { try { results[network] = await this.getContract(contractAddress, network); } catch (error) { - console.warn(`Failed to get contract for network ${network}:`, error); + this.#logger.warn( + `Failed to get contract for network ${network}:`, + error, + ); // You might want to handle this differently based on your requirements } } @@ -324,7 +342,7 @@ export class TronHttpClient { network, ); } catch (error) { - console.warn( + this.#logger.warn( `Failed to get TRC20 token metadata for network ${network}:`, error, ); diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 64c931b6..65199567 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -61,6 +61,7 @@ const trongridApiClient = new TrongridApiClient({ }); const tronHttpClient = new TronHttpClient({ configProvider, + logger, }); const tronWebFactory = new TronWebFactory({ configProvider, diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts index 23fc66df..34843493 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts @@ -35,11 +35,6 @@ describe('TransactionMapper', () => { trongridTransaction: rawTransaction, }); - console.log( - 'Native TRX Transaction Result:', - JSON.stringify(result, null, 2), - ); - const expectedTransaction = { account: 'test-account-id', type: 'send', @@ -109,11 +104,6 @@ describe('TransactionMapper', () => { trongridTransaction: rawTransaction, }); - console.log( - 'TRC10 Transaction Result:', - JSON.stringify(result, null, 2), - ); - const expectedTransaction = { account: 'test-trc10-account', type: 'send', @@ -179,11 +169,6 @@ describe('TransactionMapper', () => { trc20AssistanceData, }); - console.log( - 'TRC20 Transaction Result:', - JSON.stringify(result, null, 2), - ); - const expectedTransaction = { account: 'test-account-id', type: 'send', @@ -263,10 +248,6 @@ describe('TransactionMapper', () => { // No trc20AssistanceData provided }); - console.log( - 'TriggerSmartContract without assistance data Result:', - result, - ); expect(result).toBeNull(); }); }); @@ -281,8 +262,6 @@ describe('TransactionMapper', () => { trongridTransaction: rawTransaction, }); - console.log('Native TRX fees:', JSON.stringify(result?.fees, null, 2)); - const expectedFees = [ { asset: { @@ -310,11 +289,6 @@ describe('TransactionMapper', () => { trc20AssistanceData, }); - console.log( - 'TRC20 comprehensive fees:', - JSON.stringify(result?.fees, null, 2), - ); - const expectedFees = [ { asset: { @@ -367,19 +341,6 @@ describe('TransactionMapper', () => { trc20Transactions: trc20AssistanceData, }); - console.log( - 'Mapped multiple transaction types count:', - result.filter((tx) => tx !== null).length, - ); - console.log( - 'Sample mapped transactions:', - JSON.stringify( - result.filter((tx) => tx !== null), - null, - 2, - ), - ); - expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); // All 3 transactions map successfully (native TRX + TRC10 + TRC20) @@ -394,7 +355,6 @@ describe('TransactionMapper', () => { trc20Transactions: [], }); - console.log('Empty input result:', result); expect(result).toStrictEqual([]); }); }); @@ -415,7 +375,6 @@ describe('TransactionMapper', () => { trongridTransaction: malformedTransaction, }); - console.log('Malformed transaction result:', result); expect(result).toBeNull(); }); @@ -441,7 +400,6 @@ describe('TransactionMapper', () => { trongridTransaction: rawTransaction, }); - console.log('Unsupported contract type result:', result); expect(result).toBeNull(); }); }); @@ -456,11 +414,6 @@ describe('TransactionMapper', () => { trongridTransaction: rawTransaction, }); - console.log( - 'Shasta network transaction result:', - JSON.stringify(result, null, 2), - ); - const expectedTransaction = { account: 'test-account-id', type: 'send', diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts index 40b49270..ee45ad55 100644 --- a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts @@ -96,17 +96,11 @@ describe('TransactionsService', () => { contractInfoMock.data as ContractTransactionInfo[], ); - const result = await transactionsService.fetchTransactionsForAccount( + await transactionsService.fetchTransactionsForAccount( Network.Mainnet, mockAccount, ); - console.log('Fetched transactions count:', result.length); - console.log( - 'Sample fetched transactions:', - JSON.stringify(result.slice(0, 2), null, 2), - ); - // Verify API calls were made expect( mockTrongridApiClient.getTransactionInfoByAddress, @@ -133,17 +127,11 @@ describe('TransactionsService', () => { [], ); - const result = await transactionsService.fetchTransactionsForAccount( + await transactionsService.fetchTransactionsForAccount( Network.Mainnet, mockAccount2, ); - console.log('Fetched TRC10 transactions count:', result.length); - console.log( - 'Sample fetched TRC10 transactions:', - JSON.stringify(result.slice(0, 2), null, 2), - ); - // Verify API calls were made expect( mockTrongridApiClient.getTransactionInfoByAddress, @@ -190,7 +178,6 @@ describe('TransactionsService', () => { mockAccount, ); - console.log('Empty API response result:', result); expect(result).toStrictEqual([]); expect(true).toBe(true); }); @@ -304,11 +291,6 @@ describe('TransactionsService', () => { mockAccount2, ]); - console.log( - 'Found transactions for multiple accounts:', - JSON.stringify(result, null, 2), - ); - expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledTimes( 2, ); @@ -325,7 +307,6 @@ describe('TransactionsService', () => { it('should handle empty accounts array', async () => { const result = await transactionsService.findByAccounts([]); - console.log('Empty accounts result:', result); expect(result).toStrictEqual([]); expect(mockTransactionsRepository.findByAccountId).not.toHaveBeenCalled(); expect(true).toBe(true); @@ -369,7 +350,6 @@ describe('TransactionsService', () => { await transactionsService.save(mockTransaction); - console.log('Saved single transaction:', mockTransaction.id); expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([ mockTransaction, ]); @@ -448,15 +428,6 @@ describe('TransactionsService', () => { await transactionsService.saveMany(mockTransactions); - console.log( - 'Saved multiple transactions count:', - mockTransactions.length, - ); - console.log( - 'Saved transactions IDs:', - mockTransactions.map((tx) => tx.id), - ); - expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( mockTransactions, ); @@ -466,7 +437,6 @@ describe('TransactionsService', () => { it('should handle empty transactions array', async () => { await transactionsService.saveMany([]); - console.log('Saved empty transactions array'); expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([]); expect(true).toBe(true); }); @@ -573,10 +543,6 @@ describe('TransactionsService', () => { await transactionsService.saveMany(mockTransactions); - console.log('Grouped transactions by account:'); - console.log(`Account ${mockAccount.id}: 2 transactions`); - console.log(`Account ${mockAccount2.id}: 1 transaction`); - expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( mockTransactions, ); @@ -604,15 +570,6 @@ describe('TransactionsService', () => { // Save the fetched transactions await transactionsService.saveMany(fetchedTransactions); - console.log( - 'Complete flow - Fetched and saved transactions:', - fetchedTransactions.length, - ); - console.log( - 'Sample transaction IDs:', - fetchedTransactions.slice(0, 2).map((tx) => tx.id), - ); - expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( fetchedTransactions, ); @@ -634,17 +591,11 @@ describe('TransactionsService', () => { [], ); - const result = await transactionsService.fetchTransactionsForAccount( + await transactionsService.fetchTransactionsForAccount( Network.Mainnet, mockAccount2, ); - console.log('Mixed transaction types result:', result.length); - console.log( - 'Transaction types:', - result.map((tx) => tx.type), - ); - expect(true).toBe(true); }); }); diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx index 06f5a2e3..6f6fb0fc 100644 --- a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx @@ -74,11 +74,6 @@ export async function render( ...incomingContext, }; - console.log( - 'RENDERING CONFIRM TRANSACTION REQUEST WITH CONTEXT', - JSON.stringify(context), - ); - try { context.preferences = await snapClient.getPreferences(); } catch { From 502dcfb5d6c4b50685d2dcd5dc79c634e75cf030 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Fri, 14 Nov 2025 14:50:06 +0100 Subject: [PATCH 080/238] feat: add from and to to confirmation (#88) --- .../tron-wallet-snap/locales/de.json | 8 ++--- .../tron-wallet-snap/locales/el.json | 8 ++--- .../tron-wallet-snap/locales/en.json | 8 ++--- .../tron-wallet-snap/locales/es.json | 8 ++--- .../tron-wallet-snap/locales/fr.json | 8 ++--- .../tron-wallet-snap/locales/hi.json | 8 ++--- .../tron-wallet-snap/locales/id.json | 8 ++--- .../tron-wallet-snap/locales/ja.json | 8 ++--- .../tron-wallet-snap/locales/ko.json | 8 ++--- .../tron-wallet-snap/locales/pt.json | 8 ++--- .../tron-wallet-snap/locales/ru.json | 8 ++--- .../tron-wallet-snap/locales/tl.json | 8 ++--- .../tron-wallet-snap/locales/tr.json | 6 ++-- .../tron-wallet-snap/locales/vi.json | 8 ++--- .../tron-wallet-snap/locales/zh.json | 8 ++--- .../tron-wallet-snap/messages.json | 7 ++-- .../tron-wallet-snap/snap.manifest.json | 2 +- .../ConfirmTransactionRequest.tsx | 33 ++++++++++++------- 18 files changed, 87 insertions(+), 73 deletions(-) diff --git a/merged-packages/tron-wallet-snap/locales/de.json b/merged-packages/tron-wallet-snap/locales/de.json index 2114fcf9..27e65d57 100644 --- a/merged-packages/tron-wallet-snap/locales/de.json +++ b/merged-packages/tron-wallet-snap/locales/de.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Konto" + "confirmation.from": { + "message": "Von" }, - "confirmation.recipient": { - "message": "Empfänger" + "confirmation.to": { + "message": "An" }, "confirmation.network": { "message": "Netzwerk" diff --git a/merged-packages/tron-wallet-snap/locales/el.json b/merged-packages/tron-wallet-snap/locales/el.json index 11879edd..3b3ad032 100644 --- a/merged-packages/tron-wallet-snap/locales/el.json +++ b/merged-packages/tron-wallet-snap/locales/el.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Λογαριασμός" + "confirmation.from": { + "message": "Από" }, - "confirmation.recipient": { - "message": "Παραλήπτης" + "confirmation.to": { + "message": "Προς" }, "confirmation.network": { "message": "Δίκτυο" diff --git a/merged-packages/tron-wallet-snap/locales/en.json b/merged-packages/tron-wallet-snap/locales/en.json index a25b972f..cfaed683 100644 --- a/merged-packages/tron-wallet-snap/locales/en.json +++ b/merged-packages/tron-wallet-snap/locales/en.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Account" + "confirmation.from": { + "message": "From" }, - "confirmation.recipient": { - "message": "Recipient" + "confirmation.to": { + "message": "To" }, "confirmation.network": { "message": "Network" diff --git a/merged-packages/tron-wallet-snap/locales/es.json b/merged-packages/tron-wallet-snap/locales/es.json index 91fd027b..37a1a7f3 100644 --- a/merged-packages/tron-wallet-snap/locales/es.json +++ b/merged-packages/tron-wallet-snap/locales/es.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Cuenta" + "confirmation.from": { + "message": "De" }, - "confirmation.recipient": { - "message": "Destinatario" + "confirmation.to": { + "message": "A" }, "confirmation.network": { "message": "Red" diff --git a/merged-packages/tron-wallet-snap/locales/fr.json b/merged-packages/tron-wallet-snap/locales/fr.json index d2db9f79..d58a1745 100644 --- a/merged-packages/tron-wallet-snap/locales/fr.json +++ b/merged-packages/tron-wallet-snap/locales/fr.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Compte" + "confirmation.from": { + "message": "De" }, - "confirmation.recipient": { - "message": "Destinataire" + "confirmation.to": { + "message": "À" }, "confirmation.network": { "message": "Réseau" diff --git a/merged-packages/tron-wallet-snap/locales/hi.json b/merged-packages/tron-wallet-snap/locales/hi.json index 3f2a919b..390a65d0 100644 --- a/merged-packages/tron-wallet-snap/locales/hi.json +++ b/merged-packages/tron-wallet-snap/locales/hi.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "अकाउंट" + "confirmation.from": { + "message": "से" }, - "confirmation.recipient": { - "message": "द्वारा प्राप्त किया गया" + "confirmation.to": { + "message": "को" }, "confirmation.network": { "message": "नेटवर्क" diff --git a/merged-packages/tron-wallet-snap/locales/id.json b/merged-packages/tron-wallet-snap/locales/id.json index 9e752a19..fe756915 100644 --- a/merged-packages/tron-wallet-snap/locales/id.json +++ b/merged-packages/tron-wallet-snap/locales/id.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Akun" + "confirmation.from": { + "message": "Dari" }, - "confirmation.recipient": { - "message": "Penerima" + "confirmation.to": { + "message": "Ke" }, "confirmation.network": { "message": "Jaringan" diff --git a/merged-packages/tron-wallet-snap/locales/ja.json b/merged-packages/tron-wallet-snap/locales/ja.json index ffe9c1a8..c93d96fb 100644 --- a/merged-packages/tron-wallet-snap/locales/ja.json +++ b/merged-packages/tron-wallet-snap/locales/ja.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "アカウント" + "confirmation.from": { + "message": "送信元" }, - "confirmation.recipient": { - "message": "受取人" + "confirmation.to": { + "message": "宛先" }, "confirmation.network": { "message": "ネットワーク" diff --git a/merged-packages/tron-wallet-snap/locales/ko.json b/merged-packages/tron-wallet-snap/locales/ko.json index 262ad068..65c145ad 100644 --- a/merged-packages/tron-wallet-snap/locales/ko.json +++ b/merged-packages/tron-wallet-snap/locales/ko.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "계정" + "confirmation.from": { + "message": "보내는 주소" }, - "confirmation.recipient": { - "message": "수신자" + "confirmation.to": { + "message": "받는 주소" }, "confirmation.network": { "message": "네트워크" diff --git a/merged-packages/tron-wallet-snap/locales/pt.json b/merged-packages/tron-wallet-snap/locales/pt.json index a258bbb1..0442b855 100644 --- a/merged-packages/tron-wallet-snap/locales/pt.json +++ b/merged-packages/tron-wallet-snap/locales/pt.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Conta" + "confirmation.from": { + "message": "De" }, - "confirmation.recipient": { - "message": "Beneficiário" + "confirmation.to": { + "message": "Para" }, "confirmation.network": { "message": "Rede" diff --git a/merged-packages/tron-wallet-snap/locales/ru.json b/merged-packages/tron-wallet-snap/locales/ru.json index 2066ac16..620e256b 100644 --- a/merged-packages/tron-wallet-snap/locales/ru.json +++ b/merged-packages/tron-wallet-snap/locales/ru.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Счет" + "confirmation.from": { + "message": "От" }, - "confirmation.recipient": { - "message": "Получатель" + "confirmation.to": { + "message": "Кому" }, "confirmation.network": { "message": "Сеть" diff --git a/merged-packages/tron-wallet-snap/locales/tl.json b/merged-packages/tron-wallet-snap/locales/tl.json index 81ebdffa..94c9d49b 100644 --- a/merged-packages/tron-wallet-snap/locales/tl.json +++ b/merged-packages/tron-wallet-snap/locales/tl.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Account" + "confirmation.from": { + "message": "Mula sa" }, - "confirmation.recipient": { - "message": "Tatanggap" + "confirmation.to": { + "message": "Papunta sa" }, "confirmation.network": { "message": "Network" diff --git a/merged-packages/tron-wallet-snap/locales/tr.json b/merged-packages/tron-wallet-snap/locales/tr.json index 642b8792..3c0336d4 100644 --- a/merged-packages/tron-wallet-snap/locales/tr.json +++ b/merged-packages/tron-wallet-snap/locales/tr.json @@ -25,10 +25,10 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Hesap" + "confirmation.from": { + "message": "Gönderen" }, - "confirmation.recipient": { + "confirmation.to": { "message": "Alıcı" }, "confirmation.network": { diff --git a/merged-packages/tron-wallet-snap/locales/vi.json b/merged-packages/tron-wallet-snap/locales/vi.json index e9713369..300b54de 100644 --- a/merged-packages/tron-wallet-snap/locales/vi.json +++ b/merged-packages/tron-wallet-snap/locales/vi.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Tài khoản" + "confirmation.from": { + "message": "Từ" }, - "confirmation.recipient": { - "message": "Người nhận" + "confirmation.to": { + "message": "Đến" }, "confirmation.network": { "message": "Mạng" diff --git a/merged-packages/tron-wallet-snap/locales/zh.json b/merged-packages/tron-wallet-snap/locales/zh.json index eacd5fb7..546835d7 100644 --- a/merged-packages/tron-wallet-snap/locales/zh.json +++ b/merged-packages/tron-wallet-snap/locales/zh.json @@ -25,11 +25,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "账户" + "confirmation.from": { + "message": "从" }, - "confirmation.recipient": { - "message": "接收者" + "confirmation.to": { + "message": "到" }, "confirmation.network": { "message": "网络" diff --git a/merged-packages/tron-wallet-snap/messages.json b/merged-packages/tron-wallet-snap/messages.json index a1c47b36..2f3662e3 100644 --- a/merged-packages/tron-wallet-snap/messages.json +++ b/merged-packages/tron-wallet-snap/messages.json @@ -23,8 +23,11 @@ "confirmation.origin.tooltip": { "message": "This is the site asking for your confirmation." }, - "confirmation.account": { - "message": "Account" + "confirmation.from": { + "message": "From" + }, + "confirmation.to": { + "message": "To" }, "confirmation.network": { "message": "Network" diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index fea27391..6c534206 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "kBGVxeHulndPTqH8yULyd5hyMy041vs1uvUJxtgob18=", + "shasum": "xAp0M9FyfHgVnAelkEZkaQ20aRMhhnSTd+ZWpN0XOmk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx index 3ebc3ca3..3ced859d 100644 --- a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx @@ -8,6 +8,7 @@ import { Heading, Icon, Image, + Link, Section, Text as SnapText, Tooltip, @@ -16,6 +17,7 @@ import { import { ConfirmSignAndSendTransactionFormNames } from './events'; import { type ConfirmTransactionRequestContext } from './types'; import { Networks } from '../../../../constants'; +import { getExplorerUrl } from '../../../../utils/getExplorerUrl'; import { i18n } from '../../../../utils/i18n'; import { Asset } from '../../components/Asset/Asset'; import { Fees } from '../../components/Fees'; @@ -90,26 +92,35 @@ export const ConfirmTransactionRequest = ({ {origin} {null} - {/* Account */} + {/* From */} - {translate('confirmation.account')} + {translate('confirmation.from')} -
+ +
+ {null} - {/* Recipient */} + {/* To */} {toAddress && ( - {translate('confirmation.recipient')} + {translate('confirmation.to')} -
+ +
+ )} {null} From 296840c6f8c32e773cef7151b2e2a035df8a8854 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Fri, 14 Nov 2025 17:00:54 +0100 Subject: [PATCH 081/238] feat: transactions analytics (#90) --- .../tron-wallet-snap/snap.manifest.json | 2 +- .../src/clients/snap/SnapClient.ts | 138 ++++++++++++++++++ .../handlers/clientRequest/clientRequest.ts | 1 + .../confirmation/ConfirmationHandler.ts | 28 +++- .../src/services/send/SendService.ts | 76 ++++++++++ .../tron-wallet-snap/src/types/analytics.ts | 10 ++ 6 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/src/types/analytics.ts diff --git a/merged-packages/tron-wallet-snap/snap.manifest.json b/merged-packages/tron-wallet-snap/snap.manifest.json index 6c534206..95a5b2eb 100644 --- a/merged-packages/tron-wallet-snap/snap.manifest.json +++ b/merged-packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-tron-wallet.git" }, "source": { - "shasum": "xAp0M9FyfHgVnAelkEZkaQ20aRMhhnSTd+ZWpN0XOmk=", + "shasum": "kg2H6ptQdF6seBjNqFl94/dIzbXArAduzcivKvaBZh8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts index 8c2e52e5..24b3775f 100644 --- a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import type { JsonSLIP10Node } from '@metamask/key-tree'; import type { EntropySourceId } from '@metamask/keyring-api'; import type { @@ -10,6 +11,7 @@ import type { UpdateInterfaceResult, } from '@metamask/snaps-sdk'; +import { TransactionEventType } from '../../types/analytics'; import type { Preferences } from '../../types/snap'; /** @@ -200,4 +202,140 @@ export class SnapClient { method: 'snap_listEntropySources', }); } + + /** + * Track an event in MetaMask analytics. + * + * @param event - The event name to track. + * @param properties - Additional properties to include with the event. + */ + async trackEvent( + event: string, + properties: Record, + ): Promise { + try { + await snap.request({ + method: 'snap_trackEvent', + params: { + event: { + event, + properties, + }, + }, + }); + } catch { + // Silently fail if tracking fails - we don't want to interrupt the user flow + } + } + + /** + * Track a "Transaction Added" event when a transaction confirmation is shown. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionAdded(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionAdded, { + message: 'Snap transaction added', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Transaction Rejected" event when user rejects a transaction. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionRejected(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionRejected, { + message: 'Snap transaction rejected', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Transaction Submitted" event when a transaction is successfully broadcast. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionSubmitted(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionSubmitted, { + message: 'Snap transaction submitted', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Transaction Approved" event when a transaction is approved. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionApproved(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionApproved, { + message: 'Snap transaction approved', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Transaction Finalised" event when a transaction reaches final state. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + * @param properties.transactionType - The type of transaction. + * @param properties.transactionStatus - The status of the transaction. + */ + async trackTransactionFinalised(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + transactionType: string; + transactionStatus: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionFinalised, { + message: 'Snap transaction finalised', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + transaction_status: properties.transactionStatus, + transaction_type: properties.transactionType, + }); + } } diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 943390e5..034ecc87 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -373,6 +373,7 @@ export class ClientRequestHandler { amount, fees, asset, + accountType: account.type, }, ); diff --git a/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts index ce1875fd..0512d87e 100644 --- a/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts +++ b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts @@ -18,6 +18,8 @@ export class ConfirmationHandler { amount, fees, asset, + accountType, + origin = 'MetaMask', }: { scope: Network; fromAddress: string; @@ -25,7 +27,16 @@ export class ConfirmationHandler { amount: string; fees: ComputeFeeResult; asset: AssetEntity; + accountType: string; + origin?: string; }): Promise { + // Track Transaction Added event + await this.#snapClient.trackTransactionAdded({ + origin, + accountType, + chainIdCaip: scope, + }); + const result = await renderConfirmTransactionRequest(this.#snapClient, { scope, fromAddress, @@ -33,9 +44,24 @@ export class ConfirmationHandler { amount, fees, asset, - origin: 'MetaMask', + origin, }); + // Track Transaction Rejected event if user rejects + if (result === true) { + await this.#snapClient.trackTransactionApproved({ + origin, + accountType, + chainIdCaip: scope, + }); + } else { + await this.#snapClient.trackTransactionRejected({ + origin, + accountType, + chainIdCaip: scope, + }); + } + return result === true; } } diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts index f662b290..f5c4bd02 100644 --- a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -205,6 +205,7 @@ export class SendService { scope, fromAccountId, transaction, + origin = 'MetaMask', }: { scope: Network; fromAccountId: string; @@ -212,6 +213,7 @@ export class SendService { | Transaction | Transaction | Transaction; + origin?: string; }): Promise> { /** * Initialize TronWeb client with the account's private key @@ -229,6 +231,26 @@ export class SendService { const signedTx = await tronWeb.trx.sign(transaction); const result = await tronWeb.trx.sendRawTransaction(signedTx); + // Track Transaction Submitted event if broadcast was successful + if (result.result) { + await this.#snapClient.trackTransactionSubmitted({ + origin, + accountType: account.type, + chainIdCaip: scope, + }); + + // Track Transaction Finalised event + // Note: TRON has fast finality (3s block time with DPoS), so transactions + // are effectively finalized shortly after being submitted + await this.#snapClient.trackTransactionFinalised({ + origin, + accountType: account.type, + chainIdCaip: scope, + transactionType: 'swap', + transactionStatus: 'finalised', + }); + } + return result; } @@ -295,11 +317,13 @@ export class SendService { fromAccountId, toAddress, amount, + origin = 'MetaMask', }: { scope: Network; fromAccountId: string; toAddress: string; amount: number; + origin?: string; }): Promise { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); @@ -330,6 +354,22 @@ export class SendService { 'TRX transaction sent successfully', ); + // Track Transaction Submitted event + await this.#snapClient.trackTransactionSubmitted({ + origin, + accountType: account.type, + chainIdCaip: scope, + }); + + // Track Transaction Finalised event + await this.#snapClient.trackTransactionFinalised({ + origin, + accountType: account.type, + chainIdCaip: scope, + transactionType: 'native', + transactionStatus: 'finalised', + }); + /** * Check if the transaction's destination address is also managed by the snap. * If so, sync the account. Otherwise, only sync the source account. @@ -368,12 +408,14 @@ export class SendService { toAddress, amount, tokenId, + origin = 'MetaMask', }: { scope: Network; fromAccountId: string; toAddress: string; amount: number; tokenId: string; + origin?: string; }): Promise { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); @@ -404,6 +446,22 @@ export class SendService { 'TRC10 transaction sent successfully', ); + // Track Transaction Submitted event + await this.#snapClient.trackTransactionSubmitted({ + origin, + accountType: account.type, + chainIdCaip: scope, + }); + + // Track Transaction Finalised event + await this.#snapClient.trackTransactionFinalised({ + origin, + accountType: account.type, + chainIdCaip: scope, + transactionType: 'trc10', + transactionStatus: 'finalised', + }); + /** * Check if the transaction's destination address is also managed by the snap. * If so, sync the account. Otherwise, only sync the source account. @@ -443,6 +501,7 @@ export class SendService { contractAddress, amount, decimals, + origin = 'MetaMask', }: { scope: Network; fromAccountId: string; @@ -450,6 +509,7 @@ export class SendService { contractAddress: string; amount: number; decimals: number; + origin?: string; }): Promise { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); @@ -494,6 +554,22 @@ export class SendService { 'TRC20 transaction sent successfully', ); + // Track Transaction Submitted event + await this.#snapClient.trackTransactionSubmitted({ + origin, + accountType: account.type, + chainIdCaip: scope, + }); + + // Track Transaction Finalised event + await this.#snapClient.trackTransactionFinalised({ + origin, + accountType: account.type, + chainIdCaip: scope, + transactionType: 'trc20', + transactionStatus: 'finalised', + }); + /** * Check if the transaction's destination address is also managed by the snap. * If so, sync the account. Otherwise, only sync the source account. diff --git a/merged-packages/tron-wallet-snap/src/types/analytics.ts b/merged-packages/tron-wallet-snap/src/types/analytics.ts new file mode 100644 index 00000000..940c875d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/types/analytics.ts @@ -0,0 +1,10 @@ +/** + * Enum for transaction tracking event types. + */ +export enum TransactionEventType { + TransactionAdded = 'Transaction Added', + TransactionRejected = 'Transaction Rejected', + TransactionApproved = 'Transaction Approved', + TransactionSubmitted = 'Transaction Submitted', + TransactionFinalised = 'Transaction Finalised', +} From cd06b72d8f44dcd4571fd1b014a27f458ccef26e Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 14 Nov 2025 16:19:40 +0000 Subject: [PATCH 082/238] feat: implement confirmation UI (#86) (#91) Co-authored-by: Antonio Regadas --- .../tron-wallet-snap/jest.config.js | 1 + .../tron-wallet-snap/jest.setup.ts | 7 + .../src/clients/snap/SnapClient.ts | 47 +++ .../tron-wallet-snap/src/context.ts | 12 + .../tron-wallet-snap/src/handlers/cronjob.ts | 138 --------- .../tron-wallet-snap/src/handlers/cronjob.tsx | 276 ++++++++++++++++++ .../src/handlers/lifecycle.ts | 2 +- .../confirmation/ConfirmationHandler.ts | 34 ++- .../src/services/state/State.ts | 2 + .../confirmation/components/Asset/Asset.tsx | 34 ++- .../src/ui/confirmation/components/Fees.tsx | 68 +++-- .../ConfirmTransactionRequest.tsx | 15 +- .../ConfirmTransactionRequest/render.tsx | 42 ++- .../views/ConfirmTransactionRequest/types.ts | 7 +- .../tron-wallet-snap/src/utils/formatFiat.ts | 26 ++ .../tron-wallet-snap/src/utils/tokenToFiat.ts | 16 + 16 files changed, 547 insertions(+), 180 deletions(-) create mode 100644 merged-packages/tron-wallet-snap/jest.setup.ts delete mode 100644 merged-packages/tron-wallet-snap/src/handlers/cronjob.ts create mode 100644 merged-packages/tron-wallet-snap/src/handlers/cronjob.tsx create mode 100644 merged-packages/tron-wallet-snap/src/utils/formatFiat.ts create mode 100644 merged-packages/tron-wallet-snap/src/utils/tokenToFiat.ts diff --git a/merged-packages/tron-wallet-snap/jest.config.js b/merged-packages/tron-wallet-snap/jest.config.js index cc31c75b..9160d644 100644 --- a/merged-packages/tron-wallet-snap/jest.config.js +++ b/merged-packages/tron-wallet-snap/jest.config.js @@ -9,6 +9,7 @@ const config = { }, resetMocks: true, testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'], + setupFilesAfterEnv: ['/jest.setup.ts'], }; module.exports = config; diff --git a/merged-packages/tron-wallet-snap/jest.setup.ts b/merged-packages/tron-wallet-snap/jest.setup.ts new file mode 100644 index 00000000..7adb2fb2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/jest.setup.ts @@ -0,0 +1,7 @@ +import { config } from 'dotenv'; + +config(); + +// Set default environment for tests if not already set +// eslint-disable-next-line no-restricted-globals +process.env.ENVIRONMENT ??= 'test'; diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts index 24b3775f..bd06259a 100644 --- a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts +++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts @@ -105,6 +105,53 @@ export class SnapClient { }); } + /** + * Gets the context of an interface by its ID. + * + * @param id - The ID for the interface. + * @returns The context object associated with the interface, or null if not found. + */ + async getInterfaceContext( + id: string, + ): Promise { + const rawContext = await snap.request({ + method: 'snap_getInterfaceContext', + params: { + id, + }, + }); + + if (!rawContext) { + return null; + } + + return rawContext as TContext; + } + + /** + * Updates the context of an interface by its ID without changing the UI. + * Note: This is a helper that re-uses the existing UI. + * + * @param id - The ID for the interface. + * @param ui - The UI component. + * @param context - The updated context object. + * @returns The update interface result. + */ + async updateInterfaceWithContext>( + id: string, + ui: any, + context: TContext, + ): Promise { + return snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui, + context, + }, + }); + } + /** * Resolve a dialog using the provided ID. * diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts index 65199567..75370118 100644 --- a/merged-packages/tron-wallet-snap/src/context.ts +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -45,6 +45,7 @@ const state = new State({ assets: {}, tokenPrices: {}, transactions: {}, + mapInterfaceNameToId: {}, }, }); @@ -122,6 +123,7 @@ const stakingService = new StakingService({ const confirmationHandler = new ConfirmationHandler({ snapClient, + state, }); /** @@ -146,6 +148,8 @@ const cronHandler = new CronHandler({ logger, snapClient, accountsService, + state, + priceApiClient, }); const lifecycleHandler = new LifecycleHandler({ logger, @@ -165,6 +169,10 @@ const userInputHandler = new UserInputHandler({ }); export type SnapExecutionContext = { + /** + * Clients + */ + snapClient: SnapClient; /** * Services */ @@ -191,6 +199,10 @@ export type SnapExecutionContext = { }; const snapContext: SnapExecutionContext = { + /** + * Clients + */ + snapClient, /** * Services */ diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts b/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts deleted file mode 100644 index 11e09fd8..00000000 --- a/merged-packages/tron-wallet-snap/src/handlers/cronjob.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { JsonRpcRequest } from '@metamask/utils'; - -import type { SnapClient } from '../clients/snap/SnapClient'; -import type { AccountsService } from '../services/accounts/AccountsService'; -import type { ILogger } from '../utils/logger'; -import { createPrefixedLogger } from '../utils/logger'; - -export enum BackgroundEventMethod { - ContinuouslySynchronizeSelectedAccounts = 'onContinuouslySynchronizeAccounts', - SynchronizeSelectedAccounts = 'onSynchronizeSelectedAccounts', - SynchronizeAccounts = 'onSynchronizeAccounts', - SynchronizeAccount = 'onSynchronizeAccount', - SynchronizeAccountTransactions = 'onSynchronizeAccountTransactions', -} - -export class CronHandler { - readonly #logger: ILogger; - - readonly #accountsService: AccountsService; - - readonly #snapClient: SnapClient; - - constructor({ - logger, - accountsService, - snapClient, - }: { - logger: ILogger; - accountsService: AccountsService; - snapClient: SnapClient; - }) { - this.#logger = createPrefixedLogger(logger, '[⏰ CronHandler]'); - this.#accountsService = accountsService; - this.#snapClient = snapClient; - } - - async handle(request: JsonRpcRequest): Promise { - const { method, params } = request; - const { active } = await this.#snapClient.getClientStatus(); - - if (!active) { - return; - } - - switch (method as BackgroundEventMethod) { - case BackgroundEventMethod.ContinuouslySynchronizeSelectedAccounts: - await this.continuouslySynchronizeSelectedAccounts(); - break; - case BackgroundEventMethod.SynchronizeSelectedAccounts: - await this.synchronizeSelectedAccounts(); - break; - case BackgroundEventMethod.SynchronizeAccounts: - await this.synchronizeAccounts(params as { accountIds: string[] }); - break; - case BackgroundEventMethod.SynchronizeAccount: - await this.synchronizeAccount(params as { accountId: string }); - break; - case BackgroundEventMethod.SynchronizeAccountTransactions: - await this.synchronizeAccountTransactions( - params as { accountId: string }, - ); - break; - default: - throw new Error(`Unknown cronjob method: ${method}`); - } - } - - /** - * A background job that continuously synchronizes selected accounts. - * It schedules itself while the extension is active to make sure the data is fresh. - */ - async continuouslySynchronizeSelectedAccounts(): Promise { - this.#logger.info('[Tick] Continuously synchronizing selected accounts...'); - - await this.synchronizeSelectedAccounts(); - - await this.#snapClient.scheduleBackgroundEvent({ - method: BackgroundEventMethod.ContinuouslySynchronizeSelectedAccounts, - duration: '30s', - }); - } - - async synchronizeSelectedAccounts(): Promise { - this.#logger.info('Synchronizing selected accounts...'); - - const accounts = await this.#accountsService.getAllSelected(); - - await this.#accountsService.synchronize(accounts); - } - - async synchronizeAccounts({ - accountIds, - }: { - accountIds: string[]; - }): Promise { - this.#logger.info(`Synchronizing accounts ${accountIds.join(', ')}...`); - - const accounts = await this.#accountsService.findByIds(accountIds); - - if (!accounts) { - return; - } - - await this.#accountsService.synchronize(accounts); - } - - async synchronizeAccount({ - accountId, - }: { - accountId: string; - }): Promise { - this.#logger.info(`Synchronizing account ${accountId}...`); - - const account = await this.#accountsService.findById(accountId); - - if (!account) { - return; - } - - await this.#accountsService.synchronize([account]); - } - - async synchronizeAccountTransactions({ - accountId, - }: { - accountId: string; - }): Promise { - this.#logger.info(`Synchronizing account transactions ${accountId}...`); - - const account = await this.#accountsService.findById(accountId); - - if (!account) { - return; - } - - await this.#accountsService.synchronizeTransactions([account]); - } -} diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.tsx b/merged-packages/tron-wallet-snap/src/handlers/cronjob.tsx new file mode 100644 index 00000000..f2c96364 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.tsx @@ -0,0 +1,276 @@ +import type { JsonRpcRequest } from '@metamask/utils'; + +import type { SnapClient } from '../clients/snap/SnapClient'; +import type { AccountsService } from '../services/accounts/AccountsService'; +import type { State, UnencryptedStateValue } from '../services/state/State'; +import { ConfirmTransactionRequest } from '../ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest'; +import { + CONFIRM_TRANSACTION_INTERFACE_NAME, + type ConfirmTransactionRequestContext, +} from '../ui/confirmation/views/ConfirmTransactionRequest/types'; +import type { ILogger } from '../utils/logger'; +import { createPrefixedLogger } from '../utils/logger'; + +export enum BackgroundEventMethod { + ContinuouslySynchronizeSelectedAccounts = 'onContinuouslySynchronizeAccounts', + SynchronizeSelectedAccounts = 'onSynchronizeSelectedAccounts', + SynchronizeAccounts = 'onSynchronizeAccounts', + SynchronizeAccount = 'onSynchronizeAccount', + SynchronizeAccountTransactions = 'onSynchronizeAccountTransactions', + RefreshConfirmationPrices = 'refreshConfirmationPrices', +} + +export class CronHandler { + readonly #logger: ILogger; + + readonly #accountsService: AccountsService; + + readonly #snapClient: SnapClient; + + readonly #state: State; + + readonly #priceApiClient: any; // PriceApiClient type + + constructor({ + logger, + accountsService, + snapClient, + state, + priceApiClient, + }: { + logger: ILogger; + accountsService: AccountsService; + snapClient: SnapClient; + state: State; + priceApiClient: any; + }) { + this.#logger = createPrefixedLogger(logger, '[⏰ CronHandler]'); + this.#accountsService = accountsService; + this.#snapClient = snapClient; + this.#state = state; + this.#priceApiClient = priceApiClient; + } + + async handle(request: JsonRpcRequest): Promise { + const { method, params } = request; + const { active } = await this.#snapClient.getClientStatus(); + + if (!active) { + return; + } + + switch (method as BackgroundEventMethod) { + case BackgroundEventMethod.ContinuouslySynchronizeSelectedAccounts: + await this.continuouslySynchronizeSelectedAccounts(); + break; + case BackgroundEventMethod.SynchronizeSelectedAccounts: + await this.synchronizeSelectedAccounts(); + break; + case BackgroundEventMethod.SynchronizeAccounts: + await this.synchronizeAccounts(params as { accountIds: string[] }); + break; + case BackgroundEventMethod.SynchronizeAccount: + await this.synchronizeAccount(params as { accountId: string }); + break; + case BackgroundEventMethod.SynchronizeAccountTransactions: + await this.synchronizeAccountTransactions( + params as { accountId: string }, + ); + break; + case BackgroundEventMethod.RefreshConfirmationPrices: + await this.refreshConfirmationPrices(); + break; + default: + throw new Error(`Unknown cronjob method: ${method}`); + } + } + + /** + * A background job that continuously synchronizes selected accounts. + * It schedules itself while the extension is active to make sure the data is fresh. + */ + async continuouslySynchronizeSelectedAccounts(): Promise { + this.#logger.info('[Tick] Continuously synchronizing selected accounts...'); + + await this.synchronizeSelectedAccounts(); + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.ContinuouslySynchronizeSelectedAccounts, + duration: 'PT30S', + }); + } + + async synchronizeSelectedAccounts(): Promise { + this.#logger.info('Synchronizing selected accounts...'); + + const accounts = await this.#accountsService.getAllSelected(); + + await this.#accountsService.synchronize(accounts); + } + + async synchronizeAccounts({ + accountIds, + }: { + accountIds: string[]; + }): Promise { + this.#logger.info(`Synchronizing accounts ${accountIds.join(', ')}...`); + + const accounts = await this.#accountsService.findByIds(accountIds); + + if (!accounts) { + return; + } + + await this.#accountsService.synchronize(accounts); + } + + async synchronizeAccount({ + accountId, + }: { + accountId: string; + }): Promise { + this.#logger.info(`Synchronizing account ${accountId}...`); + + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return; + } + + await this.#accountsService.synchronize([account]); + } + + async synchronizeAccountTransactions({ + accountId, + }: { + accountId: string; + }): Promise { + this.#logger.info(`Synchronizing account transactions ${accountId}...`); + + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return; + } + + await this.#accountsService.synchronizeTransactions([account]); + } + + /** + * Background job to refresh price data for confirmation dialogs. + * Follows Solana's pattern: get interface ID from map, extract data from context. + */ + async refreshConfirmationPrices(): Promise { + this.#logger.info('Background price refresh triggered for confirmation...'); + + const mapInterfaceNameToId = + (await this.#state.getKey( + 'mapInterfaceNameToId', + )) ?? {}; + + const confirmationInterfaceId = + mapInterfaceNameToId[CONFIRM_TRANSACTION_INTERFACE_NAME]; + + // Don't do anything if the confirmation interface is not open + if (!confirmationInterfaceId) { + this.#logger.info('No active confirmation interface found'); + return; + } + + // Get the current interface context (contains all data we need!) + const interfaceContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!interfaceContext) { + this.#logger.info('Interface context no longer exists, cleaning up'); + await this.#state.setKey( + `mapInterfaceNameToId.${CONFIRM_TRANSACTION_INTERFACE_NAME}`, + null, + ); + return; + } + + try { + // Extract CAIP IDs from context (main asset + fee assets) + const assetCaipIds = [ + interfaceContext.asset.assetType, + ...interfaceContext.fees.map((fee) => fee.asset.type), + ]; + const uniqueAssetCaipIds = [...new Set(assetCaipIds)]; + + // First, update UI to show loading skeletons (Solana pattern) + const fetchingContext: ConfirmTransactionRequestContext = { + ...interfaceContext, + tokenPricesFetchStatus: 'fetching' as const, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + fetchingContext, + ); + + // Fetch fresh prices + this.#logger.info( + `Fetching fresh prices for ${uniqueAssetCaipIds.length} assets`, + ); + const prices = await this.#priceApiClient.getMultipleSpotPrices( + uniqueAssetCaipIds as any, + interfaceContext.preferences.currency, + ); + + // Update context with fresh prices + const updatedContext: ConfirmTransactionRequestContext = { + ...interfaceContext, + tokenPrices: prices, + tokenPricesFetchStatus: 'fetched' as const, + }; + + // Update the interface with new UI and context + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + updatedContext, + ); + + this.#logger.info('Successfully refreshed confirmation prices'); + + // Schedule the next refresh (20 seconds like Solana) + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationPrices, + duration: 'PT20S', + }); + } catch (error) { + this.#logger.error('Error refreshing confirmation prices:', error); + + // Try to update the UI to show error state if possible + try { + const currentContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!currentContext) { + return; + } + + const errorContext: ConfirmTransactionRequestContext = { + ...currentContext, + tokenPricesFetchStatus: 'error' as const, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + errorContext, + ); + } catch { + // Ignore errors when trying to update error state + } + + // Don't schedule another refresh on error - the dialog might be gone + } + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts b/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts index b7c1c0ca..aa258fc2 100644 --- a/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts +++ b/merged-packages/tron-wallet-snap/src/handlers/lifecycle.ts @@ -27,7 +27,7 @@ export class LifecycleHandler { await this.#snapClient.scheduleBackgroundEvent({ method: BackgroundEventMethod.ContinuouslySynchronizeSelectedAccounts, - duration: '1s', + duration: 'PT1S', }); } } diff --git a/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts index 0512d87e..4bd23f13 100644 --- a/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts +++ b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts @@ -3,12 +3,22 @@ import type { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; import { render as renderConfirmTransactionRequest } from '../../ui/confirmation/views/ConfirmTransactionRequest/render'; import type { ComputeFeeResult } from '../send/types'; +import type { State, UnencryptedStateValue } from '../state/State'; export class ConfirmationHandler { readonly #snapClient: SnapClient; - constructor({ snapClient }: { snapClient: SnapClient }) { + readonly #state: State; + + constructor({ + snapClient, + state, + }: { + snapClient: SnapClient; + state: State; + }) { this.#snapClient = snapClient; + this.#state = state; } async confirmTransactionRequest({ @@ -37,15 +47,19 @@ export class ConfirmationHandler { chainIdCaip: scope, }); - const result = await renderConfirmTransactionRequest(this.#snapClient, { - scope, - fromAddress, - toAddress, - amount, - fees, - asset, - origin, - }); + const result = await renderConfirmTransactionRequest( + this.#snapClient, + this.#state, + { + scope, + fromAddress, + toAddress, + amount, + fees, + asset, + origin: 'MetaMask', + }, + ); // Track Transaction Rejected event if user rejects if (result === true) { diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.ts b/merged-packages/tron-wallet-snap/src/services/state/State.ts index 8ed05ea1..ad421592 100644 --- a/merged-packages/tron-wallet-snap/src/services/state/State.ts +++ b/merged-packages/tron-wallet-snap/src/services/state/State.ts @@ -17,6 +17,7 @@ export type UnencryptedStateValue = { assets: Record; tokenPrices: SpotPrices; transactions: Record; + mapInterfaceNameToId: Record; }; export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { @@ -24,6 +25,7 @@ export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { assets: {}, tokenPrices: {}, transactions: {}, + mapInterfaceNameToId: {}, }; export type StateConfig> = { diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx index bea90a2f..29122a38 100644 --- a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx @@ -1,24 +1,52 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; -import { Box, Image, Text as SnapText } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Image, + Skeleton, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; + +import type { Preferences } from '../../../../types/snap'; +import { formatFiat } from '../../../../utils/formatFiat'; +import { tokenToFiat } from '../../../../utils/tokenToFiat'; type AssetProps = { symbol: string; amount: string; iconSvg: string; showAmount?: boolean; + price?: number | null; + preferences?: Preferences; + priceLoading?: boolean; }; /** - * Asset component for displaying assets with optional icon and amount. + * Asset component for displaying assets with optional icon, amount, and price. * Pure component with no business logic - just visual display. * * @param props - The props for the asset component. * @returns The rendered asset element. */ export const Asset = (props: AssetProps): ComponentOrElement => { - const { symbol, amount, iconSvg } = props; + const { symbol, amount, iconSvg, price, preferences, priceLoading } = props; + + const fiatValue = + preferences && price + ? formatFiat( + tokenToFiat(amount, price), + preferences.currency, + preferences.locale, + ) + : ''; + + const showPriceInfo = preferences !== undefined; + const showSkeleton = showPriceInfo && priceLoading; + const showFiat = showPriceInfo && !priceLoading && fiatValue; + return ( + {showSkeleton && } + {showFiat && {fiatValue}} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx index 3b8993a6..a0b84ff8 100644 --- a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx @@ -2,17 +2,26 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Box, Text as SnapText } from '@metamask/snaps-sdk/jsx'; import { Asset } from './Asset/Asset'; +import type { SpotPrices } from '../../../clients/price-api/types'; import type { ComputeFeeResult } from '../../../services/send/types'; -import type { Preferences } from '../../../types/snap'; +import type { FetchStatus, Preferences } from '../../../types/snap'; import { i18n } from '../../../utils/i18n'; type FeesProps = { fees: ComputeFeeResult; preferences: Preferences; + tokenPrices?: SpotPrices; + tokenPricesFetchStatus?: FetchStatus; }; -export const Fees = ({ fees, preferences }: FeesProps): ComponentOrElement => { +export const Fees = ({ + fees, + preferences, + tokenPrices = {}, + tokenPricesFetchStatus = 'initial', +}: FeesProps): ComponentOrElement => { const translate = i18n(preferences.locale); + const priceLoading = tokenPricesFetchStatus === 'fetching'; /** * Make sure the TRX is shown first for cases where both @@ -29,29 +38,38 @@ export const Fees = ({ fees, preferences }: FeesProps): ComponentOrElement => { return ( - {sortedFees.map((feeItem, index) => ( - - {/* Left side - show text only for first item (native TRX) */} - {index === 0 ? ( - - {translate('confirmation.transactionFee')} - - ) : ( - {null} - )} - - {/* Right side - fee value with asset display */} - - - ))} + {sortedFees.map((feeItem, index) => { + // Get the price for this specific fee asset + const feePrice = + (tokenPrices as any)[feeItem.asset.type]?.price ?? null; + + return ( + + {/* Left side - show text only for first item (native TRX) */} + {index === 0 ? ( + + {translate('confirmation.transactionFee')} + + ) : ( + {null} + )} + + {/* Right side - fee value with asset display including price */} + + + ); + })} ); }; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx index 3ced859d..bdddc28c 100644 --- a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx @@ -33,12 +33,17 @@ export const ConfirmTransactionRequest = ({ fees, preferences, networkImage, + tokenPrices, + tokenPricesFetchStatus, }, }: { context: ConfirmTransactionRequestContext; }): ComponentOrElement => { const translate = i18n(preferences.locale); + const assetPrice = tokenPrices[asset.assetType]?.price ?? null; + const priceLoading = tokenPricesFetchStatus === 'fetching'; + return ( @@ -73,6 +78,9 @@ export const ConfirmTransactionRequest = ({ amount={amount ?? ''} symbol={asset.symbol} iconSvg={asset.imageSvg ?? ''} + price={assetPrice} + preferences={preferences} + priceLoading={priceLoading} /> @@ -138,7 +146,12 @@ export const ConfirmTransactionRequest = ({ {null} {/* Fee Breakdown */} - +