diff --git a/README.md b/README.md
index 08b53cfe..c725bd70 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,16 @@
This monorepo hosts MetaMask's first-party Snaps along with shared tooling and infrastructure for building, testing, and releasing them.
+## Contributing
+
+See the [Contributor Documentation](./docs) for help on:
+
+- Setting up your development environment
+- Working with the monorepo
+- Testing changes in clients
+- Issuing new releases
+- Creating a new package
+
## Installation/Usage
Each snap in this repository has its own README with installation and usage instructions. See `packages/` for more.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..7e423c10
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,7 @@
+# Contributor Documentation
+
+Hi! Welcome to the contributor documentation for the `internal-snaps` monorepo.
+
+## Processes
+
+- [Migrating external snaps to the monorepo](./processes/snap-migration-process-guide.md)
diff --git a/docs/processes/snap-migration-process-guide.md b/docs/processes/snap-migration-process-guide.md
new file mode 100644
index 00000000..6be5334d
--- /dev/null
+++ b/docs/processes/snap-migration-process-guide.md
@@ -0,0 +1,212 @@
+# Snap migration process guide
+
+This document outlines the process for migrating a MetaMask first-party Snap into the `internal-snaps` monorepo. The migration target is assumed to be based off template-snap-monorepo.
+
+> Prerequisites: `internal-snaps` has been created and prepared to host snaps, its CI is green, and the host repo grants merge access for the migration PRs. The migration target must be in a clean state with all in-flight work merged or rebased. Install `git-filter-repo`.
+
+---
+
+## Phase A: Preparation in the original repository
+
+### [PR#1] Add the migration notice to the README
+
+Add this banner to the top of `README.md`:
+
+```markdown
+
⚠️ PLEASE READ ⚠️
This package is currently being migrated to our internal-snaps monorepo. Please do not make any commits to this repository while this migration is taking place, as they will not be transferred over. Also, please re-open PRs that are under active development in the internal-snaps repo.
+```
+
+### [PR#2] Align dependency versions and TypeScript / ESLint / Prettier / Oxfmt configurations with `internal-snaps`
+
+- If the snap's dependency versions are ahead of `internal-snaps`, update the monorepo root first; otherwise, bump the snap to match.
+- Copy the relevant tooling configs (`tsconfig.base.json` compiler flags, `.prettierrc.js`, `.oxfmtrc.json`, the shared ESLint config) so the snap satisfies them on the monorepo's tooling.
+- Preserve any TypeScript compiler flags enabled in the snap but not in the monorepo.
+- **Yarn version**: if the source repo (or the `packages/snap/package.json`) pins `yarn@3.x`, upgrade to Yarn 4 (`yarn set version berry && yarn set version stable`) and re-resolve `yarn.lock` before merging. The monorepo runs Yarn 4; a yarn-3 lockfile will be re-generated on first install in the monorepo regardless, but bumping in the source repo first avoids surprise dependency-resolution diffs at integration time.
+- Resolve any errors that result from the alignment.
+
+### [PR#3] Reconcile with `template-snap-monorepo`
+
+Compare `/packages/snap` against the current sample-snap from `internal-snaps` and add anything missing: eslint, jest config, `LICENSE`, etc.
+
+### [PR#4] Cut a final release from the source repo
+
+Cut and publish a final release of the package from the original repository. All subsequent releases happen from `internal-snaps`. Keep the release tag for the changelog link in PR#7.
+
+---
+
+## Phase B: Staging from `internal-snaps`'s `merged-packages/`
+
+### [PR#5] Migrate the source repo's git history into `merged-packages/`
+
+> [!WARNING]
+>
+> - **Do not rebase** the migration branch: it will disrupt the imported history.
+> - **Merge the PR without squashing.** Contact a maintainer to temporarily enable merge commits into `main`.
+> - Coordinate with the team to minimise the time the PR stays open; superfluous merge commits to `main` will pollute the migrated git history. If history pollution occurs, replay the migration on a fresh branch before merging.
+
+1. Navigate to a temporary directory: `cd /tmp`.
+2. Clone a fresh copy of the source repo: `git clone . **Do not** reuse an existing clone: the next step rewrites history irreversibly.
+3. `cd snap-tron-wallet`.
+4. Rewrite history so the snap package lives at the staging path:
+ This both isolates the snap package (drops the `packages/site/` test-dapp and any other top-level files) and moves it into the staging directory in a single rewrite. If the source repo is **not** itself a monorepo (i.e. the snap lives at the repo root), use `-path-rename :merged-packages/-snap/` instead.
+ `bash
+git filter-repo \\
+ --path packages/snap/ \\
+ --path-rename packages/snap/:merged-packages/tron-wallet-snap/
+`
+5. `cd` into your local checkout of `internal-snaps`.
+6. Add the rewritten repo as a remote: `git remote add tron-wallet-snap /tmp/snap-tron-wallet`.
+7. Fetch history without tags: `git fetch tron-wallet-snap --no-tags`.
+8. Create the migration branch: `git checkout -b migrate-tron-wallet-snap`.
+9. Merge the snap into the monorepo: `git merge --allow-unrelated-histories tron-wallet-snap/main`.
+10. Open a pull request in `internal-snaps` that reflects the above changes.
+
+### [PR#6] Reset the CHANGELOG, linking back to the old repository
+
+In `merged-packages/tron-wallet-snap/CHANGELOG.md`:
+
+- Replace the file with a fresh CHANGELOG that contains only an empty `## [Unreleased]` section (no historical releases).
+- Add a `### Changed` entry under `## [Unreleased]` explaining that the package was migrated, and include a link to the old changelog:
+ > This package was migrated from `MetaMask/snap-tron-wallet`. See the source repository for the original changelog.
+
+do **not** try to rewrite the old tag-diff links to point at `internal-snaps`. The old tags do not exist in the new repo, so the links would 404.
+
+### [PR#7] Remove files and directories replaced by the monorepo root
+
+Inside `merged-packages/tron-wallet-snap/`:
+
+- **Remove**: `.github/`, `.git*`, `.depcheckrc.{json,yml}`, `.yarn/`, `.yarnrc.yml`, `yarn.lock`, `.editorconfig`, `.eslint*`, `.prettier*`, `.oxfmtrc*`, `.nvm*`, `lavamoat/`, `simple-git-hooks` config, `node_modules/`, `dist/`, `coverage/`, real `.env` files (keep `.env.example` / `.env.sample`), and top-level `LICENSE` if present alongside `LICENSE.MIT0` / `LICENSE.APACHE2`.
+- **Keep**: `src/`, `tests/`, `integration-test/` (if present), `docs/` (snap-internal docs), `scripts/` (snap-internal helpers like `build-preinstalled-snap.js`, `populate-en-locale.js` / `populate-locale.js`, `update-manifest-local.js`), `CHANGELOG.md`, `package.json`, `README.md`, `jest.config.js`, `jest.integration.config.js`, `jest.setup.ts` (if present), custom jest transformers (e.g. `svg-transformer.js`), `babel.config.js` (if jest uses babel), `tsconfig*.json`, `snap.manifest.json`, `snap.config.ts`, `messages.json`, `openrpc.json`, `keyring.openrpc.json`, `images/`, `locales/`, `crowdin.yml`, `.env.example` / `.env.sample`, `LICENSE.MIT0`, `LICENSE.APACHE2`.
+
+Carry over any snap-specific Yarn patches into the monorepo root `.yarn/patches/`. For Tron, this means `tronweb-npm-6.1.0-771b242b6a.patch`. (e.g., Solana and Bitcoin have no patches; this step is no-op for them.)
+
+### [PR#8] Replace config files
+
+- `tsconfig.json`: See sample-snap config.
+- `jest.config.js`: keep the snap's existing config (it already does what `@metamask/snaps-jest` expects). Verify its `preset` / `transform` / custom `setupFilesAfterEnv` / custom transformer references (e.g. `/svg-transformer.js`) still resolve under the monorepo's hoisted dependencies. Preserve any `coverageThreshold`, `collectCoverageFrom`, `coveragePathIgnorePatterns`, and `testMatch` overrides.
+- If `jest.integration.config.js` (or any other config file) uses ESM `export default` while the rest of the snap is CommonJS, convert it to `module.exports` (or rename to `.mjs`); ESLint in the monorepo rejects the mismatch.
+- Keep `babel.config.js` if jest uses babel for transformation.
+- Add tsconfig reference paths for upstream dependencies: snaps in `internal-snaps` are leaf packages and do not consume monorepo workspaces, but this may change in the future.
+
+### [PR#9] Align dependencies and build scripts with the monorepo
+
+- Remove redundant build scripts already provided by the monorepo root.
+- Remove redundant dev dependencies already listed at the monorepo root. **Exception**: do not remove `typescript`.
+- Align dependency versions with the monorepo:
+ - If the snap version is ahead, decrement to match the monorepo (or bump the monorepo in a preceding PR).
+ - If behind, bump only when there are no resulting breaking changes to resolve.
+- **Migrate root-level `resolutions`**: copy any entries from the source repo's root `package.json#resolutions` into the monorepo root `package.json#resolutions`, merging with the existing set. Common offenders: `@metamask/snaps-sdk` (Solana, Bitcoin), `@types/react` / `@types/react-dom` (Solana). If snaps in the monorepo disagree on a pinned version, raise it to a coordination decision before merging.
+- **Migrate LavaMoat allow-list entries**: Run `yarn allow-scripts auto` to
+- **Move `crowdin.yml`** from the source repo root (or `packages/snap/`) into the package directory so the monorepo's per-package Crowdin tooling treats it independently.
+- **Snap-specific**: keep `scripts.build` starting with `mm-snap build` (the monorepo constraint only requires the prefix; chaining `&& yarn locale:build && yarn build-preinstalled-snap`: or `build:locale`, depending on the snap: is supported). Add `scripts.prepublishOnly: mm-snap manifest` so the manifest shasum stays in sync at publish time.
+- Edit `package.json` metadata:
+ - `repository.url` → `https://github.com/MetaMask/internal-snaps.git`.
+ - `homepage` → `https://github.com/MetaMask/internal-snaps/tree/main/packages/tron-wallet-snap#readme`.
+ - `bugs.url` → `https://github.com/MetaMask/internal-snaps/issues`.
+ - `license` → `(MIT-0 OR Apache-2.0)`.
+ - `files`: includes `dist/`, `snap.manifest.json`, `images/`, `locales/`.
+ - Add `scripts.changelog:update`: `../../scripts/update-changelog.sh @metamask/tron-wallet-snap`.
+ - Add `scripts.changelog:validate`: `../../scripts/validate-changelog.sh @metamask/tron-wallet-snap`.
+ - Add `scripts.since-latest-release`: `../../scripts/since-latest-release.sh`.
+
+### [PR#10] Update the README to reflect non-root-package status
+
+- Preserve the opening sentence/paragraph that introduces the snap.
+- Add or modify an "Installation" section (see other snap READMEs once they exist, or model after a core non-root package).
+- Preserve the "Usage" section.
+- Remove "Test", "Build" and other instructions for common development tasks (these now live in the monorepo's `docs/processes/`).
+- Add a "Contributing" section that links to the monorepo's `docs/processes/`.
+
+---
+
+## [PR#11] Phase C: Integration into `packages/`
+
+All Phase C steps go in a **single** PR. Coordinate with the team to minimise the time it stays open.
+
+### 1. Move the package from `merged-packages/` to `packages/`
+
+```bash
+git mv merged-packages/tron-wallet-snap packages/tron-wallet-snap
+yarn install
+```
+
+Check the snap's tests pass: `yarn workspace @metamask/tron-wallet-snap run test`.
+
+### 2. Linter and constraints fixes
+
+- `yarn constraints --fix` then `yarn constraints` until clean.
+- Verify `scripts.changelog:validate` is present in `package.json` (added in PR#10).
+- `yarn allow-scripts auto` if the snap brought new lifecycle scripts. Commit the LavaMoat policy update.
+- `yarn lint:eslint --fix --suppress-all` to absorb existing snap-side findings into `eslint-suppressions.json`, then `yarn lint` to confirm clean.
+- Update `teams.json`: add `"metamask/tron-wallet-snap": "@MetaMask/networks"` (or the owning team).
+- Update `.github/CODEOWNERS`: append `/packages/tron-wallet-snap/ @MetaMask/networks`.
+
+### 4. Resolve or TODO downstream errors
+
+If the migration breaks anything inside the monorepo (rare, since snaps are leaf packages):
+
+- Resolve simple errors in this PR.
+- Mark complex/blocked errors with `@ts-expect-error TODO:` and file a follow-up issue.
+
+### 5. Record changes in CHANGELOG `[Unreleased]`
+
+In `packages/tron-wallet-snap/CHANGELOG.md` under `## [Unreleased]`, e.g.:
+
+- `### Changed`
+ - Migrated package from `MetaMask/snap-tron-wallet` to `MetaMask/internal-snaps` (no consumer-visible behaviour change).
+ - Re-licensed split into `LICENSE.MIT0` + `LICENSE.APACHE2` files (SPDX expression unchanged).
+
+### 6. Finalize merge
+
+- Confirm all tests pass for the snap and for the rest of the monorepo (CI must be green).
+- Double-check that any changes that landed on `main` while the PR was open are correctly merged in.
+
+---
+
+## Phase D: Clean-up and release
+
+### Source repo (`snap-tron-wallet`)
+
+1. **Transfer open issues** from the source repo into `internal-snaps` using GitHub's _Transfer issue_ feature. Prepend the title with `[tron-wallet-snap]`.
+2. **Lock open PRs** (do not provide a reason). Leave this comment:
+
+ ```markdown
+ This library has now been migrated into the internal-snaps monorepo. This PR has been locked and this repo will be archived shortly. Going forward, releases of this library will only include changes made in the internal-snaps repo.
+
+ - Please push this branch to internal-snaps and open a new PR there.
+ - Optionally, add a link pointing to the discussion in this PR to provide context.
+ ```
+
+ For important PRs, manually migrate them into `internal-snaps` or create follow-up tickets.
+
+3. **[PR#12] Replace the migration notice in `snap-tron-wallet/README.md`** with the archive notice (this replaces the banner added in PR#1):
+
+ ```html
+
+
+
+
⚠️ PLEASE READ ⚠️
+
+ This package has been migrated to our
+ internal-snaps
+ monorepo, and this repository has been archived. Please note that all
+ future development and feature releases will take place in the
+ internal-snaps
+ repository.
+
+
+
+
+ ```
+
+4. **Archive the source repository**. Contact a maintainer to perform this step. Keep tags and the final release intact for npm-package history continuity.
+
+### `internal-snaps`
+
+1. **Fix any `@ts-expect-error TODO:` annotations** added during PR#12. Do this before the first post-migration release if possible.
+2. **[PR#13] Cut the first post-migration release** with `yarn create-release-branch -i`.
+3. **Verify the preview-build workflow** by opening a no-op PR against the new package and confirming the preview-publish job posts.
diff --git a/merged-packages/tron-wallet-snap/.depcheckrc.json b/merged-packages/tron-wallet-snap/.depcheckrc.json
new file mode 100644
index 00000000..c31d8118
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/.depcheckrc.json
@@ -0,0 +1,3 @@
+{
+ "ignores": ["jest-transform-stub", "ts-jest", "@metamask/auto-changelog"]
+}
diff --git a/merged-packages/tron-wallet-snap/.env.example b/merged-packages/tron-wallet-snap/.env.example
new file mode 100644
index 00000000..1444ae2f
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/.env.example
@@ -0,0 +1,31 @@
+# Use:
+# - local for local development
+# - test for running tests locally (mandatory)
+# - production before submitting a PR
+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
+
+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
+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
+
+# 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
+
+# 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
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..4f00279d
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/CHANGELOG.md
@@ -0,0 +1,589 @@
+# 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]
+
+### Added
+
+- Adds `setKeyWith` helper for state read-modify-write operations ([#307](https://github.com/MetaMask/snap-tron-wallet/pull/307))
+
+## [1.25.8]
+
+### Fixed
+
+- Removed `snap_trackError` permission from snap manifest ([#320](https://github.com/MetaMask/snap-tron-wallet/pull/320))
+ - This permission was causing errors during snap installation and is not necessary for the snap's functionality.
+
+## [1.25.7]
+
+### Added
+
+- Add `SnapClient.trackError` wrapper around `snap_trackError` for reporting errors to Sentry ([#311](https://github.com/MetaMask/snap-tron-wallet/pull/311))
+- Add `trackError` call in `scanTransaction` method of `TransactionScanService` ([#314](https://github.com/MetaMask/snap-tron-wallet/pull/314))
+- Add `trackError` calls `handle` methods of `ClientRequestHandler` ([#315](https://github.com/MetaMask/snap-tron-wallet/pull/315))
+
+### Changed
+
+- Add `FetchStatus.Loading` in `FetchStatus` enum to represent first fetching state and add `isFetchStatusLoadingOrFetching` helper ([#302](https://github.com/MetaMask/snap-tron-wallet/pull/302))
+- Make Confirm button stay enabled after the first security scan even during refresh ([#302](https://github.com/MetaMask/snap-tron-wallet/pull/302))
+
+## [1.25.6]
+
+### Added
+
+- Filter native TRX receive spam from transaction history ([#301](https://github.com/MetaMask/snap-tron-wallet/pull/301))
+
+### Fixed
+
+- Refresh stale TRON transaction expiration and reference block metadata before confirmation security scans, signing, and broadcasting ([#299](https://github.com/MetaMask/snap-tron-wallet/pull/299))
+
+## [1.25.5]
+
+### Fixed
+
+- Validate transaction `owner_address` against the signer address derived from the persisted account before signing or broadcasting ([#297](https://github.com/MetaMask/snap-tron-wallet/pull/297))
+
+## [1.25.4]
+
+### Changed
+
+- Validate that account IDs passed to `keyring_setSelectedAccounts` belong to the snap, rejecting unknown IDs with `InvalidParamsError` ([#298](https://github.com/MetaMask/snap-tron-wallet/pull/298))
+- Simplify `SnapClient` interface methods by removing defensive `*IfExists` wrappers and aligning with the Solana snap's approach ([#257](https://github.com/MetaMask/snap-tron-wallet/pull/257))
+
+## [1.25.3]
+
+### Fixed
+
+- Token balances now update correctly after a swap or transfer. ([#292](https://github.com/MetaMask/snap-tron-wallet/pull/292))
+- Remove interface ID after it is no longer needed ([#284](https://github.com/MetaMask/snap-tron-wallet/pull/284))
+
+## [1.25.2]
+
+### Fixed
+
+- Relax Trongrid `internal_transactions` validation to avoid sync failures on sparse legacy payloads ([#289](https://github.com/MetaMask/snap-tron-wallet/pull/289))
+- Handle TRC10 token identifiers correctly in the current Trongrid account-history transaction flow, without mis-parsing endpoint-specific `asset_name` formats ([#289](https://github.com/MetaMask/snap-tron-wallet/pull/289))
+- Decouple assets and transactions synchronization so one failure does not prevent the other from completing ([#289](https://github.com/MetaMask/snap-tron-wallet/pull/289))
+
+## [1.25.1]
+
+### Fixed
+
+- Correctly display token icon size when sending TRC20 tokens ([#262](https://github.com/MetaMask/snap-tron-wallet/pull/262))
+- Correctly display decimal amount sent in confirmation dialog ([#264](https://github.com/MetaMask/snap-tron-wallet/pull/264))
+- Confirm `fee_limit` is always present when building transaction ([#280](https://github.com/MetaMask/snap-tron-wallet/pull/280))
+- Include memo fee (1 TRX) in fee estimation logic ([#281](https://github.com/MetaMask/snap-tron-wallet/pull/281))
+- Handle NFT asset types in transaction scan simulation ([#285](https://github.com/MetaMask/snap-tron-wallet/pull/285))
+
+## [1.25.0]
+
+### Changed
+
+- Optimize account discovery by using a lightweight activity check (`limit=1`) instead of fetching full transaction history ([#252](https://github.com/MetaMask/snap-tron-wallet/pull/252))
+
+### Fixed
+
+- Avoid `onAmountInput` fee estimation failures by skipping fee validation until a recipient address is available ([#259](https://github.com/MetaMask/snap-tron-wallet/pull/259))
+- Assert transaction structure at all entry points, rejecting malformed transactions ([#237](https://github.com/MetaMask/snap-tron-wallet/pull/237))
+- Disable scanning of unsupported contract types, preventing incorrect security alerts from blocking user flows ([#238](https://github.com/MetaMask/snap-tron-wallet/pull/238))
+ - Supported transactions are those single-contract interaction transactions of the following types: `TransferContract`, `CreateSmartContract`, `TriggerSmartContract`.
+ - Unsupported transactions will show empty estimated changes and allow the user to proceed without blocking the confirmation.
+- Correctly fetch and return staking rewards ([#242](https://github.com/MetaMask/snap-tron-wallet/pull/242))
+- Fix revert simulation error when sending TRC20 tokens ([#261](https://github.com/MetaMask/snap-tron-wallet/pull/261))
+- Fix infinite loading during fee estimation ([#258](https://github.com/MetaMask/snap-tron-wallet/pull/258))
+ - The issue was caused by a deadlock during cache updates of chain parameters.
+
+## [1.24.0]
+
+### Added
+
+- Implement `claimUnstakedTrx` client request method ([#231](https://github.com/MetaMask/snap-tron-wallet/pull/231))
+- Implement `claimTrxStakingRewards` client request method ([#232](https://github.com/MetaMask/snap-tron-wallet/pull/232))
+- Add confirmation dialog to `claimUnstakedTrx` method ([#236](https://github.com/MetaMask/snap-tron-wallet/pull/236))
+
+### Fixed
+
+- Always extract special assets to prevent stale balances when amounts drop to zero ([#234](https://github.com/MetaMask/snap-tron-wallet/pull/234))
+- Add confirmation dialog to `claimUnstakedTrx` method ([#236](https://github.com/MetaMask/snap-tron-wallet/pull/236))
+
+## [1.23.1]
+
+### Changed
+
+- Tweak asset symbols for the 3 new staking special assets: "In Lock Period" TRX, "Ready for Withdrawal" TRX and "Staking Rewards" TRX ([#227](https://github.com/MetaMask/snap-tron-wallet/pull/227))
+
+## [1.23.0]
+
+### Added
+
+- Add "Ready for Withdrawal" TRX as a special asset to display unstaked TRX that has completed the withdrawal period and is ready to be claimed ([#208](https://github.com/MetaMask/snap-tron-wallet/pull/208))
+- Add "Staking Rewards" TRX asset to display unclaimed voting rewards ([#209](https://github.com/MetaMask/snap-tron-wallet/pull/209))
+- Return "In Lock Period" TRX as a special asset showing TRX that is unstaked but still in the 14-day lock period ([#210](https://github.com/MetaMask/snap-tron-wallet/pull/210))
+
+## [1.22.1]
+
+### Fixed
+
+- Correct account discovery by using address-based activity checks instead of account IDs ([#206](https://github.com/MetaMask/snap-tron-wallet/pull/206))
+
+## [1.22.0]
+
+### Added
+
+- Support fetching TRC20 token balances for inactive accounts using fallback endpoint ([#190](https://github.com/MetaMask/snap-tron-wallet/pull/190))
+- Add security scanning for tokens sends ([#205](https://github.com/MetaMask/snap-tron-wallet/pull/205))
+
+### Fixed
+
+- Decode hex-encoded TRC10 token names and symbols from Full Node API responses ([#187](https://github.com/MetaMask/snap-tron-wallet/pull/187))
+- Gracefully handle dismissed interface contexts during background refresh operations ([#188](https://github.com/MetaMask/snap-tron-wallet/pull/188/changes))
+- Add spent bandwidth from staking as part of the calculation of current bandwidth levels. For both energy and bandwidth, clamp the result between maximum and 0 to avoid negative values ([#197](https://github.com/MetaMask/snap-tron-wallet/pull/197))
+
+## [1.21.1]
+
+### Fixed
+
+- Sent transactions stuck on `pending` state ([#194](https://github.com/MetaMask/snap-tron-wallet/pull/194))
+
+## [1.21.0]
+
+### Fixed
+
+- Handle extreme token amounts to prevent NaN and scientific notation in estimated changes ([#191](https://github.com/MetaMask/snap-tron-wallet/pull/191))
+- Consider contract deployer's energy when calculating fees ([#192](https://github.com/MetaMask/snap-tron-wallet/pull/192))
+
+## [1.20.0]
+
+### Added
+
+- Support estimating network fees with contract energy sharing mechanism ([#181](https://github.com/MetaMask/snap-tron-wallet/pull/181))
+
+### Changed
+
+- Optimize data synchronization to avoid duplicate API requests ([#173](https://github.com/MetaMask/snap-tron-wallet/pull/173))
+- Cache chain parameters until they expire ([#171](https://github.com/MetaMask/snap-tron-wallet/pull/171))
+
+### Fixed
+
+- Add pre-confirmation validation to `confirmSend` flow ([#179](https://github.com/MetaMask/snap-tron-wallet/pull/179))
+ - Validates that the user has enough funds to cover both the amount and all associated fees (bandwidth, energy, account activation) before showing the confirmation dialog.
+- Fix missing values in simulation API request ([#176](https://github.com/MetaMask/snap-tron-wallet/pull/176))
+ - The wrong parameters sent to the simulation API were causing inaccurate estimations and false negatives.
+- Ensure integer amounts are passed to TronWeb's functions that involve `SUN` ([#178](https://github.com/MetaMask/snap-tron-wallet/pull/178))
+- Correct inaccurate energy estimation for system contracts ([#172](https://github.com/MetaMask/snap-tron-wallet/pull/172))
+- Correct mapping of `approve` type transactions ([#177](https://github.com/MetaMask/snap-tron-wallet/pull/177))
+
+## [1.19.3]
+
+### Fixed
+
+- Normalize locale string when showing simulation estimated changes ([#169](https://github.com/MetaMask/snap-tron-wallet/pull/169))
+
+## [1.19.2]
+
+### Added
+
+- Display error message coming from transaction simulations ([#166](https://github.com/MetaMask/snap-tron-wallet/pull/166))
+
+## [1.19.1]
+
+### Changed
+
+- Remove convertion layer for images displayed in Snaps UI ([#162](https://github.com/MetaMask/snap-tron-wallet/pull/162))
+
+### Fixed
+
+- Finalised word transformed to us-en ([#164](https://github.com/MetaMask/snap-tron-wallet/pull/164))
+- Signing messages of 3 characters or less ([#161](https://github.com/MetaMask/snap-tron-wallet/pull/161))
+
+## [1.19.0]
+
+### Fixed
+
+- Always include TRX fee as first result on `computeFee` ([#159](https://github.com/MetaMask/snap-tron-wallet/pull/159))
+- Ignore `visible` in `handleComputeFee` field ([#158](https://github.com/MetaMask/snap-tron-wallet/pull/158))
+- Address unsubmitable swaps from Bridgers ([#157](https://github.com/MetaMask/snap-tron-wallet/pull/157))
+
+## [1.18.0]
+
+### Added
+
+- Add optional `srNodeAddress` param to `confirmStake` ([#154](https://github.com/MetaMask/snap-tron-wallet/pull/154))
+- Implement token filtering by minimum USD value in `AssetsService` ([#152](https://github.com/MetaMask/snap-tron-wallet/pull/152))
+
+### Fixed
+
+- Use `sha256` from MM utils and remove pkey usage ([#151](https://github.com/MetaMask/snap-tron-wallet/pull/151))
+
+## [1.17.0]
+
+### Added
+
+- Map TRC10 transaction history with metadata fetched onchain ([#141](https://github.com/MetaMask/snap-tron-wallet/pull/141))
+
+### Fixed
+
+- Address all `minor` level audit findings ([#149](https://github.com/MetaMask/snap-tron-wallet/pull/149))
+- Address all `major` level audit findings ([#148](https://github.com/MetaMask/snap-tron-wallet/pull/148))
+- Exclude permissive origins and permissions ([#145](https://github.com/MetaMask/snap-tron-wallet/pull/145))
+
+## [1.16.1]
+
+### Fixed
+
+- Dapp connectivity confirmations ([#146](https://github.com/MetaMask/snap-tron-wallet/pull/146))
+
+## [1.16.0]
+
+### Added
+
+- Integrate Blockaid transaction simulation ([#139](https://github.com/MetaMask/snap-tron-wallet/pull/139))
+
+### Fixed
+
+- Enhance FeeCalculatorService with fallback energy estimation to `feeLimit` ([#142](https://github.com/MetaMask/snap-tron-wallet/pull/142))
+- Audit fixes ([#140](https://github.com/MetaMask/snap-tron-wallet/pull/140))
+- Stop signing transactions before user confirmation ([#138](https://github.com/MetaMask/snap-tron-wallet/pull/138))
+
+## [1.15.1]
+
+### Changed
+
+- Bump `keyring-api` to version 21.3.0 ([#136](https://github.com/MetaMask/snap-tron-wallet/pull/136))
+
+## [1.15.0]
+
+### Added
+
+- Added `feeLimit` option for energy calculation in FeeCalculatorService ([#132](https://github.com/MetaMask/snap-tron-wallet/pull/132))
+- Added `signTransaction` confirmation ([#131](https://github.com/MetaMask/snap-tron-wallet/pull/131))
+- Added `signMessage` confirmation ([#130](https://github.com/MetaMask/snap-tron-wallet/pull/130))
+- Allocate Tron power to Consensys' SR node ([#129](https://github.com/MetaMask/snap-tron-wallet/pull/129))
+- Added compute staking fee ([#112](https://github.com/MetaMask/snap-tron-wallet/pull/112))
+
+### Fixed
+
+- `signTransaction` not rebuilding Tron transactions correctly after receiving them as input ([#128](https://github.com/MetaMask/snap-tron-wallet/pull/128))
+- `computeFee` does not consider account activations ([#127](https://github.com/MetaMask/snap-tron-wallet/pull/127))
+- Remove validation key from WalletService ([#126](https://github.com/MetaMask/snap-tron-wallet/pull/126))
+
+## [1.14.0]
+
+### Added
+
+- Dapp connectivity methods (`sign{Message/Transaction}`) ([#124](https://github.com/MetaMask/snap-tron-wallet/pull/124))
+- Client `signRewardsMessage` ([#119](https://github.com/MetaMask/snap-tron-wallet/pull/119))
+
+### Changed
+
+- Improve Send flow amount validation with fee estimation ([#123](https://github.com/MetaMask/snap-tron-wallet/pull/123))
+
+### Fixed
+
+- Det `isDev` to false ([#122](https://github.com/MetaMask/snap-tron-wallet/pull/122))
+
+## [1.13.0]
+
+### Changed
+
+- Ensure only safe concurrent state operations ([#116](https://github.com/MetaMask/snap-tron-wallet/pull/116))
+
+## [1.12.1]
+
+### Fixed
+
+- Continuous synchronization of accounts not starting until we locked and unlocked the client ([#117](https://github.com/MetaMask/snap-tron-wallet/pull/117))
+- Could not send TRC20 tokens where decimals were `18` ([#115](https://github.com/MetaMask/snap-tron-wallet/pull/115))
+
+## [1.12.0]
+
+### Changed
+
+- `computeFee` not calling `triggerConstantContract` with accurate parameters ([#113](https://github.com/MetaMask/snap-tron-wallet/pull/113))
+
+## [1.11.0]
+
+### Added
+
+- Pending transaction when executing ([#110](https://github.com/MetaMask/snap-tron-wallet/pull/110))
+
+## [1.10.1]
+
+### Fixed
+
+- `computeFee` error ([#108](https://github.com/MetaMask/snap-tron-wallet/pull/108))
+- Map freeze/unfreeze txs ([#107](https://github.com/MetaMask/snap-tron-wallet/pull/107))
+
+## [1.10.0]
+
+### Fixed
+
+- Compute fee accuracy ([#103](https://github.com/MetaMask/snap-tron-wallet/pull/103))
+- Fix `getAccount` and `listAccounts` ([#105](https://github.com/MetaMask/snap-tron-wallet/pull/105))
+- Don't remove tron resources from assets ([#104](https://github.com/MetaMask/snap-tron-wallet/pull/104))
+
+## [1.9.1]
+
+### Fixed
+
+- Use available `triggerConstantContract` instead of `estimateEnergy` ([#101](https://github.com/MetaMask/snap-tron-wallet/pull/101))
+- Use mutex for state blob modifications ([#93](https://github.com/MetaMask/snap-tron-wallet/pull/93))
+
+## [1.9.0]
+
+### Added
+
+- Track transaction when executed and map `failed` and `swap` transactions ([#98](https://github.com/MetaMask/snap-tron-wallet/pull/98))
+
+### Fixed
+
+- Dont allow clients requesting assets for tesnets ([#99](https://github.com/MetaMask/snap-tron-wallet/pull/99))
+
+## [1.8.1]
+
+### Added
+
+- Bandwidth and Energy confirmation logos ([#95](https://github.com/MetaMask/snap-tron-wallet/pull/95))
+
+### Fixed
+
+- `computeFee` was returning in SUN and inaccurate values ([#84](https://github.com/MetaMask/snap-tron-wallet/pull/84))
+
+## [1.8.0]
+
+### Added
+
+- Confirmation UI (#86) ([#86](https://github.com/MetaMask/snap-tron-wallet/pull/86))
+- Transactions analytics ([#90](https://github.com/MetaMask/snap-tron-wallet/pull/90))
+- Add `from` and `to` to confirmation ([#88](https://github.com/MetaMask/snap-tron-wallet/pull/88))
+
+### Fixed
+
+- Remove logs ([#87](https://github.com/MetaMask/snap-tron-wallet/pull/87))
+
+## [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
+
+- Use Infura urls ([#75](https://github.com/MetaMask/snap-tron-wallet/pull/75))
+
+## [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]
+
+### Changed
+
+- Remove unused "Localnet" ([#73](https://github.com/MetaMask/snap-tron-wallet/pull/73))
+
+### Fixed
+
+- 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]
+
+### Changed
+
+- Add `options` to the unstake method ([#69](https://github.com/MetaMask/snap-tron-wallet/pull/69))
+
+## [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
+
+- 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
+
+- 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
+
+- Make field `visible` configurable by caller on the `signAndSendTransaction` handler ([#59](https://github.com/MetaMask/snap-tron-wallet/pull/59))
+
+## [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
+
+- 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
+
+- 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
+
+- 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
+
+- 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
+
+- `signAndSendTransaction` client request ([#34](https://github.com/MetaMask/snap-tron-wallet/pull/34))
+
+## [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
+
+- 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
+
+- Clean unnecessary values ([#22](https://github.com/MetaMask/snap-tron-wallet/pull/22))
+
+## [1.0.2]
+
+### Changed
+
+- Release config update
+
+## [1.0.1]
+
+### Added
+
+- Enable corepack ([#17](https://github.com/MetaMask/snap-tron-wallet/pull/17))
+
+## [1.0.0]
+
+### Added
+
+- 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.25.8...HEAD
+[1.25.8]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.25.7...v1.25.8
+[1.25.7]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.25.6...v1.25.7
+[1.25.6]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.25.5...v1.25.6
+[1.25.5]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.25.4...v1.25.5
+[1.25.4]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.25.3...v1.25.4
+[1.25.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.25.2...v1.25.3
+[1.25.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.25.1...v1.25.2
+[1.25.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.25.0...v1.25.1
+[1.25.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.24.0...v1.25.0
+[1.24.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.23.1...v1.24.0
+[1.23.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.23.0...v1.23.1
+[1.23.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.22.1...v1.23.0
+[1.22.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.22.0...v1.22.1
+[1.22.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.21.1...v1.22.0
+[1.21.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.21.0...v1.21.1
+[1.21.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.20.0...v1.21.0
+[1.20.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.19.3...v1.20.0
+[1.19.3]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.19.2...v1.19.3
+[1.19.2]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.19.1...v1.19.2
+[1.19.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.19.0...v1.19.1
+[1.19.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.18.0...v1.19.0
+[1.18.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.17.0...v1.18.0
+[1.17.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.16.1...v1.17.0
+[1.16.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.16.0...v1.16.1
+[1.16.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.15.1...v1.16.0
+[1.15.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.15.0...v1.15.1
+[1.15.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.14.0...v1.15.0
+[1.14.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.13.0...v1.14.0
+[1.13.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.12.1...v1.13.0
+[1.12.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.12.0...v1.12.1
+[1.12.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.11.0...v1.12.0
+[1.11.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.10.1...v1.11.0
+[1.10.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.10.0...v1.10.1
+[1.10.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.9.1...v1.10.0
+[1.9.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.9.0...v1.9.1
+[1.9.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.8.1...v1.9.0
+[1.8.1]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.8.0...v1.8.1
+[1.8.0]: https://github.com/MetaMask/snap-tron-wallet/compare/v1.7.4...v1.8.0
+[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
+[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
+[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
+[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
+[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
+[1.0.0]: https://github.com/MetaMask/snap-tron-wallet/releases/tag/v1.0.0
diff --git a/merged-packages/tron-wallet-snap/README.md b/merged-packages/tron-wallet-snap/README.md
new file mode 100644
index 00000000..9582ba73
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/README.md
@@ -0,0 +1,85 @@
+# Tron 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
+ },
+ },
+ },
+});
+```
+
+### `confirmStake`
+
+Stakes TRX for bandwidth or energy resources. Votes are automatically allocated to a Super Representative (SR) node after staking.
+
+**Parameters:**
+
+| Parameter | Type | Required | Description |
+| ----------------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------- |
+| `fromAccountId` | `string` | Yes | The UUID of the account to stake from |
+| `assetId` | `string` | Yes | The CAIP-19 asset ID (e.g., `tron:728126428/slip44:195`) |
+| `value` | `string` | Yes | The amount of TRX to stake |
+| `options.purpose` | `'ENERGY' \| 'BANDWIDTH'` | Yes | The resource type to stake for |
+| `options.srNodeAddress` | `string` | No | Optional SR node address to allocate votes to. If not provided, defaults to the Consensys SR node |
+
+**Example:**
+
+```typescript
+// Stake with default Consensys SR node
+provider.request({
+ method: 'wallet_invokeSnap',
+ params: {
+ snapId,
+ request: {
+ method: 'confirmStake',
+ params: {
+ fromAccountId: 'account-uuid',
+ assetId: 'tron:728126428/slip44:195',
+ value: '100',
+ options: {
+ purpose: 'ENERGY',
+ },
+ },
+ },
+ },
+});
+
+// Stake with custom SR node address
+provider.request({
+ method: 'wallet_invokeSnap',
+ params: {
+ snapId,
+ request: {
+ method: 'confirmStake',
+ params: {
+ fromAccountId: 'account-uuid',
+ assetId: 'tron:728126428/slip44:195',
+ value: '100',
+ options: {
+ purpose: 'BANDWIDTH',
+ srNodeAddress: 'TCustomSRNodeAddress1234567890abc',
+ },
+ },
+ },
+ },
+});
+```
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..dcae5d95
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/images/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
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/jest.config.js b/merged-packages/tron-wallet-snap/jest.config.js
new file mode 100644
index 00000000..565ebb52
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/jest.config.js
@@ -0,0 +1,46 @@
+// @ts-check
+/**
+ * @type {import('ts-jest').JestConfigWithTsJest}
+ */
+const config = {
+ // Indicates whether the coverage information should be collected while executing the test
+ collectCoverage: true,
+
+ // An array of glob patterns indicating a set of files for which coverage information should be collected
+ collectCoverageFrom: ['./src/**/*.ts', './src/**/*.tsx'],
+
+ // The directory where Jest should output its coverage files
+ coverageDirectory: 'coverage',
+
+ // An array of regexp pattern strings used to skip coverage collection
+ coveragePathIgnorePatterns: ['.*/index\\.ts'],
+
+ // Indicates which provider should be used to instrument code for coverage
+ coverageProvider: 'babel',
+
+ // A list of reporter names that Jest uses when writing coverage reports
+ coverageReporters: ['text', 'html', 'json-summary', 'lcov'],
+
+ // An object that configures minimum threshold enforcement for coverage results
+ coverageThreshold: {
+ global: {
+ branches: 62.15,
+ functions: 68.7,
+ lines: 77.61,
+ statements: 77.6,
+ },
+ },
+
+ preset: '@metamask/snaps-jest',
+ transform: {
+ '^.+\\.(t|j)sx?$': 'ts-jest',
+ },
+ moduleNameMapper: {
+ '\\.svg$': 'jest-transform-stub',
+ },
+ 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.integration.config.js b/merged-packages/tron-wallet-snap/jest.integration.config.js
new file mode 100644
index 00000000..c36c839e
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/jest.integration.config.js
@@ -0,0 +1,10 @@
+// @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/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/locales/de.json b/merged-packages/tron-wallet-snap/locales/de.json
new file mode 100644
index 00000000..171274d6
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/de.json
@@ -0,0 +1,107 @@
+{
+ "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": "Dies ist die Website, die um Ihre Bestätigung bittet."
+ },
+ "confirmation.from": {
+ "message": "Von"
+ },
+ "confirmation.to": {
+ "message": "An"
+ },
+ "confirmation.network": {
+ "message": "Netzwerk"
+ },
+ "confirmation.transactionFee": {
+ "message": "Netzwerk-Gebühr"
+ },
+ "confirmation.confirmButton": {
+ "message": "Bestätigen"
+ },
+ "confirmation.cancelButton": {
+ "message": "Stornieren"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Nachricht signieren"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Nachricht"
+ },
+ "confirmation.account": {
+ "message": "Konto"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Transaktion signieren"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Geschätzte Änderungen sind nicht verfügbar"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Keine geschätzten Änderungen"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Aufgrund eines Fehlers konnten wir nicht auf Sicherheitswarnungen prüfen."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Fahren Sie nur fort, wenn Sie jeder beteiligten Adresse vertrauen."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Diese Transaktion wurde während der Simulation rückgängig gemacht."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Dies ist eine irreführende Anfrage"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Wenn Sie diese Anfrage genehmigen, wird ein Dritter, der für Betrügereien bekannt ist, alle Ihre Vermögenswerte nehmen."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Mehr erfahren"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Sicherheitsberatung von"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Ein unbekannter Fehler ist aufgetreten"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Unzureichendes Guthaben"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Ungültige Transaktion"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Unzureichende Mittel"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Ungültige Adresse"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Nicht unterstützte Methode"
+ }
+ }
+}
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..fa5d3910
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/el.json
@@ -0,0 +1,107 @@
+{
+ "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": "Αυτός είναι ο ιστότοπος που ζητά την επιβεβαίωσή σας."
+ },
+ "confirmation.from": {
+ "message": "Από"
+ },
+ "confirmation.to": {
+ "message": "Προς"
+ },
+ "confirmation.network": {
+ "message": "Δίκτυο"
+ },
+ "confirmation.transactionFee": {
+ "message": "Τέλη δικτύου"
+ },
+ "confirmation.confirmButton": {
+ "message": "Επιβεβαίωση"
+ },
+ "confirmation.cancelButton": {
+ "message": "Άκυρο"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Υπογραφή μηνύματος"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Μήνυμα"
+ },
+ "confirmation.account": {
+ "message": "Λογαριασμός"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Sign transaction"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Estimated changes are not available"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "No estimated changes"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Because of an error, we couldn't check for security alerts."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Only continue if you trust every address involved."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "This transaction was reverted during simulation."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "This is a deceptive request"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "If you approve this request, a third party known for scams will take all your assets."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Learn more"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Security advice by"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "An unknown error occurred"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Insufficient balance"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Invalid transaction"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Ανεπαρκή κεφάλαια"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Μη έγκυρη διεύθυνση"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Μη υποστηριζόμενη μέθοδος"
+ }
+ }
+}
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..741163f9
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/en.json
@@ -0,0 +1,110 @@
+{
+ "locale": "en",
+ "messages": {
+ "confirmation.transaction.title": {
+ "message": "Transaction request"
+ },
+ "confirmation.estimatedChanges.title": {
+ "message": "Estimated changes"
+ },
+ "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."
+ },
+ "confirmation.estimatedChanges.send": {
+ "message": "You send"
+ },
+ "confirmation.bandwidthConsumed": {
+ "message": "Bandwidth consumed"
+ },
+ "confirmation.estimatedChanges.receive": {
+ "message": "You receive"
+ },
+ "confirmation.origin": {
+ "message": "Request from"
+ },
+ "confirmation.origin.tooltip": {
+ "message": "This is the site asking for your confirmation."
+ },
+ "confirmation.from": {
+ "message": "From"
+ },
+ "confirmation.to": {
+ "message": "To"
+ },
+ "confirmation.network": {
+ "message": "Network"
+ },
+ "confirmation.transactionFee": {
+ "message": "Network fee"
+ },
+ "confirmation.confirmButton": {
+ "message": "Confirm"
+ },
+ "confirmation.cancelButton": {
+ "message": "Cancel"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Sign message"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Message"
+ },
+ "confirmation.account": {
+ "message": "Account"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Sign transaction"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Estimated changes are not available"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "No estimated changes"
+ },
+ "confirmation.estimatedChanges.unsupportedContract": {
+ "message": "Unsupported contract for simulation"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Because of an error, we couldn't check for security alerts."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Only continue if you trust every address involved."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "This transaction was reverted during simulation."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "This is a deceptive request"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "If you approve this request, a third party known for scams will take all your assets."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Learn more"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Security advice by"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "An unknown error occurred"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Insufficient balance"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Invalid transaction"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Insufficient funds"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Invalid address"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Unsupported method"
+ }
+ }
+}
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..41b64c10
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/es.json
@@ -0,0 +1,110 @@
+{
+ "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": "Este es el sitio que solicita su confirmación."
+ },
+ "confirmation.from": {
+ "message": "De"
+ },
+ "confirmation.to": {
+ "message": "A"
+ },
+ "confirmation.network": {
+ "message": "Red"
+ },
+ "confirmation.transactionFee": {
+ "message": "Tarifa de red"
+ },
+ "confirmation.confirmButton": {
+ "message": "Confirmar"
+ },
+ "confirmation.cancelButton": {
+ "message": "Cancelar"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Firmar mensaje"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Mensaje"
+ },
+ "confirmation.account": {
+ "message": "Cuenta"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Firmar transacción"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Los cambios estimados no están disponibles"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Sin cambios estimados"
+ },
+ "confirmation.estimatedChanges.unsupportedContract": {
+ "message": "Contrato no compatible con simulación"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Debido a un error, no pudimos verificar alertas de seguridad."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Solo continúe si confía en cada dirección involucrada."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Esta transacción se revirtió durante la simulación."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Esta es una solicitud engañosa"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Si aprueba esta solicitud, un tercero conocido por estafas tomará todos sus activos."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Más información"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Asesoramiento de seguridad por"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Ocurrió un error desconocido"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Saldo insuficiente"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Transacción inválida"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Fondos insuficientes"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Dirección inválida"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Método no compatible"
+ }
+ }
+}
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..0952febb
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/fr.json
@@ -0,0 +1,107 @@
+{
+ "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": "Ceci est le site qui demande votre confirmation."
+ },
+ "confirmation.from": {
+ "message": "De"
+ },
+ "confirmation.to": {
+ "message": "À"
+ },
+ "confirmation.network": {
+ "message": "Réseau"
+ },
+ "confirmation.transactionFee": {
+ "message": "Frais de réseau"
+ },
+ "confirmation.confirmButton": {
+ "message": "Confirmer"
+ },
+ "confirmation.cancelButton": {
+ "message": "Annuler"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Signer le message"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Message"
+ },
+ "confirmation.account": {
+ "message": "Compte"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Signer la transaction"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Les changements estimés ne sont pas disponibles"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Aucun changement estimé"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "En raison d'une erreur, nous n'avons pas pu vérifier les alertes de sécurité."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Ne continuez que si vous faites confiance à chaque adresse impliquée."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Cette transaction a été annulée lors de la simulation."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Il s'agit d'une demande trompeuse"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Si vous approuvez cette demande, un tiers connu pour des escroqueries prendra tous vos actifs."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "En savoir plus"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Conseils de sécurité par"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Une erreur inconnue s'est produite"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Solde insuffisant"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Transaction invalide"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Fonds insuffisants"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Adresse invalide"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Méthode non prise en charge"
+ }
+ }
+}
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..fbc2d4d4
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/hi.json
@@ -0,0 +1,107 @@
+{
+ "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": "यह वह साइट है जो आपकी पुष्टि मांग रही है।"
+ },
+ "confirmation.from": {
+ "message": "से"
+ },
+ "confirmation.to": {
+ "message": "को"
+ },
+ "confirmation.network": {
+ "message": "नेटवर्क"
+ },
+ "confirmation.transactionFee": {
+ "message": "नेटवर्क फीस"
+ },
+ "confirmation.confirmButton": {
+ "message": "कन्फर्म करें"
+ },
+ "confirmation.cancelButton": {
+ "message": "कैंसिल करें"
+ },
+ "confirmation.signMessage.title": {
+ "message": "संदेश साइन करें"
+ },
+ "confirmation.signMessage.message": {
+ "message": "संदेश"
+ },
+ "confirmation.account": {
+ "message": "अकाउंट"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "ट्रांसेक्शन साइन करें"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "अनुमानित बदलाव उपलब्ध नहीं हैं"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "कोई अनुमानित बदलाव नहीं"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "एक त्रुटि के कारण, हम सुरक्षा अलर्ट की जांच नहीं कर सके।"
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "केवल तभी जारी रखें जब आप हर शामिल पते पर भरोसा करते हैं।"
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "यह ट्रांसेक्शन सिमुलेशन के दौरान वापस कर दिया गया था।"
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "यह एक धोखाधड़ी वाला अनुरोध है"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "यदि आप इस अनुरोध को स्वीकृत करते हैं, तो घोटालों के लिए जानी जाने वाली एक तीसरी पार्टी आपकी सभी संपत्तियां ले लेगी।"
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "और जानें"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "सुरक्षा सलाह द्वारा"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "एक अज्ञात त्रुटि हुई"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "अपर्याप्त शेष राशि"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "अमान्य ट्रांसेक्शन"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "अपर्याप्त धनराशि"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "अमान्य पता"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "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..bc86e559
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/id.json
@@ -0,0 +1,107 @@
+{
+ "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": "Ini adalah situs yang meminta konfirmasi Anda."
+ },
+ "confirmation.from": {
+ "message": "Dari"
+ },
+ "confirmation.to": {
+ "message": "Ke"
+ },
+ "confirmation.network": {
+ "message": "Jaringan"
+ },
+ "confirmation.transactionFee": {
+ "message": "Biaya jaringan"
+ },
+ "confirmation.confirmButton": {
+ "message": "Konfirmasikan"
+ },
+ "confirmation.cancelButton": {
+ "message": "Batal"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Tanda tangani pesan"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Pesan"
+ },
+ "confirmation.account": {
+ "message": "Akun"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Tanda tangani transaksi"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Estimasi perubahan tidak tersedia"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Tidak ada estimasi perubahan"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Karena kesalahan, kami tidak dapat memeriksa peringatan keamanan."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Hanya lanjutkan jika Anda mempercayai setiap alamat yang terlibat."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Transaksi ini dikembalikan selama simulasi."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Ini adalah permintaan yang menipu"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Jika Anda menyetujui permintaan ini, pihak ketiga yang dikenal melakukan penipuan akan mengambil semua aset Anda."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Pelajari lebih lanjut"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Saran keamanan oleh"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Terjadi kesalahan yang tidak diketahui"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Saldo tidak mencukupi"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Transaksi tidak valid"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Dana tidak mencukupi"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Alamat tidak valid"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Metode tidak didukung"
+ }
+ }
+}
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..a9dc2107
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/ja.json
@@ -0,0 +1,107 @@
+{
+ "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": "これは確認を求めているサイトです。"
+ },
+ "confirmation.from": {
+ "message": "送信元"
+ },
+ "confirmation.to": {
+ "message": "宛先"
+ },
+ "confirmation.network": {
+ "message": "ネットワーク"
+ },
+ "confirmation.transactionFee": {
+ "message": "ネットワーク手数料"
+ },
+ "confirmation.confirmButton": {
+ "message": "確定"
+ },
+ "confirmation.cancelButton": {
+ "message": "キャンセル"
+ },
+ "confirmation.signMessage.title": {
+ "message": "メッセージに署名"
+ },
+ "confirmation.signMessage.message": {
+ "message": "メッセージ"
+ },
+ "confirmation.account": {
+ "message": "アカウント"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "トランザクションに署名"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "予測される増減額は利用できません"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "予測される増減額はありません"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "エラーのため、セキュリティアラートを確認できませんでした。"
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "関係するすべてのアドレスを信頼できる場合にのみ続行してください。"
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "このトランザクションはシミュレーション中に元に戻されました。"
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "これは欺瞞的なリクエストです"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "このリクエストを承認すると、詐欺で知られる第三者があなたのすべての資産を取得します。"
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "詳細を見る"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "セキュリティアドバイス提供元"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "不明なエラーが発生しました"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "残高不足"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "無効なトランザクション"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "資金不足"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "無効なアドレス"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "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..c0427267
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/ko.json
@@ -0,0 +1,107 @@
+{
+ "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": "확인을 요청하는 사이트입니다."
+ },
+ "confirmation.from": {
+ "message": "보내는 주소"
+ },
+ "confirmation.to": {
+ "message": "받는 주소"
+ },
+ "confirmation.network": {
+ "message": "네트워크"
+ },
+ "confirmation.transactionFee": {
+ "message": "네트워크 수수료"
+ },
+ "confirmation.confirmButton": {
+ "message": "컨펌"
+ },
+ "confirmation.cancelButton": {
+ "message": "취소"
+ },
+ "confirmation.signMessage.title": {
+ "message": "메시지 서명"
+ },
+ "confirmation.signMessage.message": {
+ "message": "메시지"
+ },
+ "confirmation.account": {
+ "message": "계정"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "트랜잭션 서명"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "예상 변동 사항을 사용할 수 없습니다"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "예상 변동 사항 없음"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "오류로 인해 보안 경고를 확인할 수 없습니다."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "관련된 모든 주소를 신뢰하는 경우에만 계속하세요."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "이 트랜잭션은 시뮬레이션 중에 되돌려졌습니다."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "이것은 기만적인 요청입니다"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "이 요청을 승인하면 사기로 알려진 제3자가 모든 자산을 가져갑니다."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "자세히 알아보기"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "보안 조언 제공"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "알 수 없는 오류가 발생했습니다"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "잔액 부족"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "무효한 트랜잭션"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "자금 부족"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "무효한 주소"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "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..81f1c049
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/pt.json
@@ -0,0 +1,107 @@
+{
+ "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": "Este é o site que está solicitando sua confirmação."
+ },
+ "confirmation.from": {
+ "message": "De"
+ },
+ "confirmation.to": {
+ "message": "Para"
+ },
+ "confirmation.network": {
+ "message": "Rede"
+ },
+ "confirmation.transactionFee": {
+ "message": "Taxa de rede"
+ },
+ "confirmation.confirmButton": {
+ "message": "Confirmar"
+ },
+ "confirmation.cancelButton": {
+ "message": "Cancelar"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Assinar mensagem"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Mensagem"
+ },
+ "confirmation.account": {
+ "message": "Conta"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Assinar transação"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Alterações estimadas não estão disponíveis"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Sem alterações estimadas"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Devido a um erro, não pudemos verificar alertas de segurança."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Só continue se você confiar em cada endereço envolvido."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Esta transação foi revertida durante a simulação."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Este é um pedido enganoso"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Se você aprovar este pedido, um terceiro conhecido por golpes pegará todos os seus ativos."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Saiba mais"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Consultoria de segurança por"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Ocorreu um erro desconhecido"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Saldo insuficiente"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Transação inválida"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Fundos insuficientes"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Endereço inválido"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Método não suportado"
+ }
+ }
+}
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..01fe1c21
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/ru.json
@@ -0,0 +1,107 @@
+{
+ "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": "Это сайт, запрашивающий ваше подтверждение."
+ },
+ "confirmation.from": {
+ "message": "От"
+ },
+ "confirmation.to": {
+ "message": "Кому"
+ },
+ "confirmation.network": {
+ "message": "Сеть"
+ },
+ "confirmation.transactionFee": {
+ "message": "Комиссия сети"
+ },
+ "confirmation.confirmButton": {
+ "message": "Подтвердить"
+ },
+ "confirmation.cancelButton": {
+ "message": "Отмена"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Подписать сообщение"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Сообщение"
+ },
+ "confirmation.account": {
+ "message": "Аккаунт"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Подписать транзакцию"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Прогнозируемые изменения недоступны"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Нет прогнозируемых изменений"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Из-за ошибки мы не смогли проверить предупреждения безопасности."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Продолжайте только если доверяете каждому адресу."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Эта транзакция была отменена во время симуляции."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Это обманчивый запрос"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Если вы одобрите этот запрос, третья сторона, известная мошенничеством, заберет все ваши активы."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Узнать больше"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Совет по безопасности от"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Произошла неизвестная ошибка"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Недостаточный баланс"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Недействительная транзакция"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Недостаточно средств"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Неверный адрес"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "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..ad68e387
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/tl.json
@@ -0,0 +1,107 @@
+{
+ "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": "Ito ang site na humihiling ng iyong kumpirmasyon."
+ },
+ "confirmation.from": {
+ "message": "Mula sa"
+ },
+ "confirmation.to": {
+ "message": "Papunta sa"
+ },
+ "confirmation.network": {
+ "message": "Network"
+ },
+ "confirmation.transactionFee": {
+ "message": "Bayad sa network"
+ },
+ "confirmation.confirmButton": {
+ "message": "Kumpirmahin"
+ },
+ "confirmation.cancelButton": {
+ "message": "Kanselahin"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Pirmahan ang mensahe"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Mensahe"
+ },
+ "confirmation.account": {
+ "message": "Account"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Pirmahan ang transaksyon"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Hindi available ang tinatayang mga pagbabago"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Walang tinatayang mga pagbabago"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Dahil sa isang error, hindi namin masuri ang mga alerto sa seguridad."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Magpatuloy lamang kung nagtitiwala ka sa bawat address na kasangkot."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Ang transaksyong ito ay na-revert sa panahon ng simulation."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Ito ay isang mapanlinlang na kahilingan"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Kung aprubahan mo ang kahilingang ito, isang third party na kilala sa mga scam ay kukuha ng lahat ng iyong assets."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Matuto pa"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Payo sa seguridad mula sa"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Naganap ang hindi kilalang error"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Kulang ang balanse"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Hindi wastong transaksyon"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Kulang ang pondo"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Hindi wastong address"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Hindi suportadong paraan"
+ }
+ }
+}
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..f0311286
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/tr.json
@@ -0,0 +1,107 @@
+{
+ "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": "Bu, onayınızı isteyen sitedir."
+ },
+ "confirmation.from": {
+ "message": "Gönderen"
+ },
+ "confirmation.to": {
+ "message": "Alıcı"
+ },
+ "confirmation.network": {
+ "message": "Ağ"
+ },
+ "confirmation.transactionFee": {
+ "message": "Ağ ücreti"
+ },
+ "confirmation.confirmButton": {
+ "message": "Onayla"
+ },
+ "confirmation.cancelButton": {
+ "message": "İptal"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Mesajı imzala"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Mesaj"
+ },
+ "confirmation.account": {
+ "message": "Hesap"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "İşlemi imzala"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Tahmini değişiklikler mevcut değil"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Tahmini değişiklik yok"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Bir hata nedeniyle güvenlik uyarılarını kontrol edemedik."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Yalnızca ilgili her adrese güveniyorsanız devam edin."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Bu işlem simülasyon sırasında geri alındı."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Bu aldatıcı bir istektir"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Bu isteği onaylarsanız, dolandırıcılıkla bilinen bir üçüncü taraf tüm varlıklarınızı alacaktır."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Daha fazla bilgi"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Güvenlik tavsiyesi sağlayan"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Bilinmeyen bir hata oluştu"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Yetersiz bakiye"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Geçersiz işlem"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Yetersiz fon"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Geçersiz adres"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Desteklenmeyen yöntem"
+ }
+ }
+}
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..2585bc3b
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/vi.json
@@ -0,0 +1,107 @@
+{
+ "locale": "vi",
+ "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": "Đây là trang web yêu cầu xác nhận của bạn."
+ },
+ "confirmation.from": {
+ "message": "Từ"
+ },
+ "confirmation.to": {
+ "message": "Đến"
+ },
+ "confirmation.network": {
+ "message": "Mạng"
+ },
+ "confirmation.transactionFee": {
+ "message": "Phí mạng"
+ },
+ "confirmation.confirmButton": {
+ "message": "Xác nhận"
+ },
+ "confirmation.cancelButton": {
+ "message": "Hủy"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Ký tin nhắn"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Tin nhắn"
+ },
+ "confirmation.account": {
+ "message": "Tài khoản"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Ký giao dịch"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Thay đổi ước tính không khả dụng"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "Không có thay đổi ước tính"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Do lỗi, chúng tôi không thể kiểm tra cảnh báo bảo mật."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Chỉ tiếp tục nếu bạn tin tưởng mọi địa chỉ liên quan."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "Giao dịch này đã bị hoàn nguyên trong quá trình mô phỏng."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "Đây là một yêu cầu lừa đảo"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "Nếu bạn phê duyệt yêu cầu này, một bên thứ ba được biết đến với các vụ lừa đảo sẽ lấy tất cả tài sản của bạn."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Tìm hiểu thêm"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Tư vấn bảo mật bởi"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "Đã xảy ra lỗi không xác định"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Số dư không đủ"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Giao dịch không hợp lệ"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Không đủ tiền"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Địa chỉ không hợp lệ"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Phương thức không được hỗ trợ"
+ }
+ }
+}
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..9c0b312c
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/locales/zh.json
@@ -0,0 +1,107 @@
+{
+ "locale": "zh",
+ "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": "这是请求您确认的网站。"
+ },
+ "confirmation.from": {
+ "message": "从"
+ },
+ "confirmation.to": {
+ "message": "到"
+ },
+ "confirmation.network": {
+ "message": "网络"
+ },
+ "confirmation.transactionFee": {
+ "message": "网络费"
+ },
+ "confirmation.confirmButton": {
+ "message": "确认"
+ },
+ "confirmation.cancelButton": {
+ "message": "取消"
+ },
+ "confirmation.signMessage.title": {
+ "message": "签署消息"
+ },
+ "confirmation.signMessage.message": {
+ "message": "消息"
+ },
+ "confirmation.account": {
+ "message": "账户"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "签署交易"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "预计变化不可用"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "无预计变化"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "由于错误,我们无法检查安全警报。"
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "只有在信任所有相关地址时才继续。"
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "该交易在模拟过程中被撤销。"
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "这是一个欺骗性请求"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "如果您批准此请求,一个以诈骗闻名的第三方将获取您的所有资产。"
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "了解更多"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "安全建议提供方"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "发生了未知错误"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "余额不足"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "无效交易"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "资金不足"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "无效地址"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "不支持的方法"
+ }
+ }
+}
diff --git a/merged-packages/tron-wallet-snap/messages.json b/merged-packages/tron-wallet-snap/messages.json
new file mode 100644
index 00000000..f141fc9a
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/messages.json
@@ -0,0 +1,107 @@
+{
+ "confirmation.transaction.title": {
+ "message": "Transaction request"
+ },
+ "confirmation.estimatedChanges.title": {
+ "message": "Estimated changes"
+ },
+ "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."
+ },
+ "confirmation.estimatedChanges.send": {
+ "message": "You send"
+ },
+ "confirmation.bandwidthConsumed": {
+ "message": "Bandwidth consumed"
+ },
+ "confirmation.estimatedChanges.receive": {
+ "message": "You receive"
+ },
+ "confirmation.origin": {
+ "message": "Request from"
+ },
+ "confirmation.origin.tooltip": {
+ "message": "This is the site asking for your confirmation."
+ },
+ "confirmation.from": {
+ "message": "From"
+ },
+ "confirmation.to": {
+ "message": "To"
+ },
+ "confirmation.network": {
+ "message": "Network"
+ },
+ "confirmation.transactionFee": {
+ "message": "Network fee"
+ },
+ "confirmation.confirmButton": {
+ "message": "Confirm"
+ },
+ "confirmation.cancelButton": {
+ "message": "Cancel"
+ },
+ "confirmation.signMessage.title": {
+ "message": "Sign message"
+ },
+ "confirmation.signMessage.message": {
+ "message": "Message"
+ },
+ "confirmation.account": {
+ "message": "Account"
+ },
+ "confirmation.signTransaction.title": {
+ "message": "Sign transaction"
+ },
+ "confirmation.estimatedChanges.notAvailable": {
+ "message": "Estimated changes are not available"
+ },
+ "confirmation.estimatedChanges.noChanges": {
+ "message": "No estimated changes"
+ },
+ "confirmation.estimatedChanges.unsupportedContract": {
+ "message": "Unsupported contract for simulation"
+ },
+ "confirmation.simulationTitleAPIError": {
+ "message": "Because of an error, we couldn't check for security alerts."
+ },
+ "confirmation.simulationMessageAPIError": {
+ "message": "Only continue if you trust every address involved."
+ },
+ "confirmation.simulationErrorTitle": {
+ "message": "This transaction was reverted during simulation."
+ },
+ "confirmation.simulationErrorSubtitle": {
+ "message": "{reason}"
+ },
+ "confirmation.validationErrorTitle": {
+ "message": "This is a deceptive request"
+ },
+ "confirmation.validationErrorSubtitle": {
+ "message": "If you approve this request, a third party known for scams will take all your assets."
+ },
+ "confirmation.validationErrorLearnMore": {
+ "message": "Learn more"
+ },
+ "confirmation.validationErrorSecurityAdviced": {
+ "message": "Security advice by"
+ },
+ "transactionScan.errors.unknownError": {
+ "message": "An unknown error occurred"
+ },
+ "transactionScan.errors.insufficientBalance": {
+ "message": "Insufficient balance"
+ },
+ "transactionScan.errors.invalidTransaction": {
+ "message": "Invalid transaction"
+ },
+ "transactionScan.errors.insufficientFunds": {
+ "message": "Insufficient funds"
+ },
+ "transactionScan.errors.invalidAddress": {
+ "message": "Invalid address"
+ },
+ "transactionScan.errors.unsupportedEIP712Message": {
+ "message": "Unsupported method"
+ }
+}
diff --git a/merged-packages/tron-wallet-snap/package.json b/merged-packages/tron-wallet-snap/package.json
new file mode 100644
index 00000000..d07d4727
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@metamask/tron-wallet-snap",
+ "version": "1.25.8",
+ "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:dev": "ENVIRONMENT=local node scripts/update-manifest-local.js && mm-snap build && yarn build:locale",
+ "build:prod": "ENVIRONMENT=production node scripts/update-manifest-local.js && 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\"",
+ "changelog:update": "../../scripts/update-changelog.sh @metamask/tron-wallet-snap",
+ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/tron-wallet-snap",
+ "clean": "rimraf dist",
+ "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types",
+ "lint:deps": "depcheck && yarn dedupe --check",
+ "lint:deps:fix": "depcheck && yarn dedupe",
+ "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",
+ "format": "prettier '**/*.ts' '**/*.tsx' --write",
+ "prepublishOnly": "mm-snap manifest",
+ "publish:preview": "yarn npm publish --tag preview",
+ "serve": "mm-snap serve",
+ "start": "node scripts/update-manifest-local.js && concurrently \"mm-snap watch\" \"yarn build:locale:watch\"",
+ "test": "jest && yarn jest-it-up",
+ "test:integration": "./integration-test/run-integration.sh"
+ },
+ "devDependencies": {
+ "@metamask/auto-changelog": "^3.4.4",
+ "@metamask/key-tree": "^10.1.1",
+ "@metamask/keyring-api": "^21.3.0",
+ "@metamask/keyring-snap-sdk": "^7.1.0",
+ "@metamask/snaps-cli": "^8.3.0",
+ "@metamask/snaps-jest": "^9.8.0",
+ "@metamask/snaps-sdk": "^10.3.0",
+ "@metamask/superstruct": "^3.2.1",
+ "@metamask/utils": "^11.9.0",
+ "@types/jest": "^30.0.0",
+ "@types/lodash": "^4.17.20",
+ "async-mutex": "^0.5.0",
+ "bignumber.js": "^9.3.1",
+ "concurrently": "^9.2.0",
+ "dotenv": "^17.0.0",
+ "ethers": "^6.15.0",
+ "jest": "^30.0.3",
+ "jest-it-up": "^2.0.2",
+ "jest-transform-stub": "2.0.0",
+ "lodash": "^4.17.21",
+ "prettier": "^3.5.3",
+ "tronweb": "patch:tronweb@npm%3A6.1.0#~/.yarn/patches/tronweb-npm-6.1.0-771b242b6a.patch",
+ "ts-jest": "^29.4.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..cb4517eb
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/scripts/build-preinstalled-snap.js
@@ -0,0 +1,76 @@
+// @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/scripts/update-manifest-local.js b/merged-packages/tron-wallet-snap/scripts/update-manifest-local.js
new file mode 100644
index 00000000..0bb2fbbb
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/scripts/update-manifest-local.js
@@ -0,0 +1,56 @@
+const fs = require('fs');
+const path = require('path');
+require('dotenv').config();
+
+const manifestPath = path.join(__dirname, '..', 'snap.manifest.json');
+const environment = process.env.ENVIRONMENT || 'local';
+const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+
+if (environment === 'local' || environment === 'test') {
+ manifest.initialConnections['http://localhost:3000'] = {};
+ if (manifest.initialPermissions?.['endowment:keyring']?.allowedOrigins) {
+ if (!manifest.initialPermissions['endowment:keyring'].allowedOrigins.includes('http://localhost:3000')) {
+ manifest.initialPermissions['endowment:keyring'].allowedOrigins.push('http://localhost:3000');
+ }
+ }
+
+ // Add endowment:rpc permission for local/dev mode
+ manifest.initialPermissions['endowment:rpc'] = {
+ dapps: true,
+ snaps: false
+ };
+
+ console.log('Added localhost entries and endowment:rpc to snap.manifest.json for local development');
+} else {
+ // Production mode - remove local-only settings
+ let changed = false;
+
+ // Remove localhost from initialConnections
+ if (manifest.initialConnections?.['http://localhost:3000']) {
+ delete manifest.initialConnections['http://localhost:3000'];
+ changed = true;
+ }
+
+ // Remove localhost from keyring allowedOrigins
+ if (manifest.initialPermissions?.['endowment:keyring']?.allowedOrigins) {
+ const origins = manifest.initialPermissions['endowment:keyring'].allowedOrigins;
+ const index = origins.indexOf('http://localhost:3000');
+ if (index > -1) {
+ origins.splice(index, 1);
+ changed = true;
+ }
+ }
+
+ // Remove endowment:rpc permission
+ if (manifest.initialPermissions?.['endowment:rpc']) {
+ delete manifest.initialPermissions['endowment:rpc'];
+ changed = true;
+ }
+
+ if (changed) {
+ console.log('Removed local-only settings from snap.manifest.json for production');
+ }
+}
+
+fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
+
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..ed7d1ef2
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/snap.config.ts
@@ -0,0 +1,42 @@
+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_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 ?? '',
+ // Block explorer
+ 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 ?? '',
+ 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 ?? '',
+ // 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 ?? '',
+ // 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 ?? '',
+ },
+ polyfills: 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..f5671a02
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/snap.manifest.json
@@ -0,0 +1,55 @@
+{
+ "version": "1.25.8",
+ "description": "Manage Tron using MetaMask",
+ "proposedName": "Tron",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/MetaMask/snap-tron-wallet.git"
+ },
+ "source": {
+ "shasum": "FLtT7DMMWdSqMF56F6C0GbG0+gtL7Am/CTSPckQQorc=",
+ "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": {}
+ },
+ "initialPermissions": {
+ "endowment:keyring": {
+ "allowedOrigins": ["https://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": "onSynchronizeSelectedAccountsCronjob"
+ }
+ }
+ ]
+ },
+ "endowment:assets": {
+ "scopes": ["tron:728126428"]
+ }
+ },
+ "platformVersion": "10.3.0",
+ "manifestVersion": "0.1"
+}
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..8a7a71f4
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/ICache.ts
@@ -0,0 +1,97 @@
+/**
+ * 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..b490e16c
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/InMemoryCache.ts
@@ -0,0 +1,182 @@
+import { assert } from '@metamask/utils';
+
+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.
+ *
+ * WARNINGS:
+ * - This cache is not persistent and will be lost when the process is restarted.
+ */
+export class InMemoryCache implements ICache {
+ readonly #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..60d4d702
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.test.ts
@@ -0,0 +1,799 @@
+/* eslint-disable jest/prefer-strict-equal */
+/* eslint-disable @typescript-eslint/naming-convention */
+import { StateCache } from './StateCache';
+import { InMemoryState } from '../services/state/InMemoryState';
+
+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);
+ const mockDateNow = jest
+ .spyOn(Date, 'now')
+ .mockReturnValue(1704067200000); // January 1, 2024
+
+ 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)
+ },
+ },
+ });
+
+ // 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 () => {
+ 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);
+
+ // 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 () => {
+ 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);
+
+ // 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();
+
+ expect(stateValue).toStrictEqual({
+ __cache__default: {
+ someOtherKey: {
+ value: 'someOtherValue',
+ expiresAt: Number.MAX_SAFE_INTEGER,
+ },
+ },
+ });
+
+ mockDateNow.mockRestore();
+ });
+ });
+
+ 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..75f7944c
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/StateCache.ts
@@ -0,0 +1,270 @@
+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';
+
+/**
+ * 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 {
+ readonly #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);
+
+ // If cache is not initialized, return empty object
+ if (!cacheStore) {
+ return {};
+ }
+
+ const keysAndValues = Object.entries(cacheStore).filter(([key]) =>
+ keys.includes(key),
+ );
+
+ const expiredKeys = keysAndValues.filter(
+ ([_unused, cacheEntry]) =>
+ cacheEntry && cacheEntry.expiresAt < Date.now(),
+ );
+
+ await this.mdelete(expiredKeys.map(([key]) => key));
+
+ 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;
+ }
+ });
+
+ // 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 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);
+ });
+
+ // 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..f20203dc
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/types.ts
@@ -0,0 +1,11 @@
+import type { Serializable } from '../utils/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..ae17158b
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/useCache.test.ts
@@ -0,0 +1,245 @@
+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
+ 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..3c413fce
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/useCache.ts
@@ -0,0 +1,93 @@
+/* eslint-disable no-void */
+
+import type { ICache } from './ICache';
+import logger from '../utils/logger';
+import type { Serializable } from '../utils/serialization/types';
+
+/**
+ * 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.
+ */
+ // TODO: Replace `any` with type
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ 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.
+ */
+// TODO: Replace `any` with type
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const defaultGenerateCacheKey = (functionName: string, args: any[]): string =>
+ `${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.
+ */
+// TODO: Replace `any` with type
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+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
+ logger.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) => {
+ logger.error(`Cache set error for key "${cacheKey}":`, error);
+ });
+
+ return result;
+ };
+};
diff --git a/merged-packages/tron-wallet-snap/src/caching/useCacheUntil.test.ts b/merged-packages/tron-wallet-snap/src/caching/useCacheUntil.test.ts
new file mode 100644
index 00000000..0e849904
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/useCacheUntil.test.ts
@@ -0,0 +1,331 @@
+import type { ICache } from './ICache';
+import {
+ useCacheUntil,
+ type CacheUntilOptions,
+ type ResultWithExpiry,
+} from './useCacheUntil';
+import type { Serializable } from '../utils/serialization/types';
+
+describe('useCacheUntil', () => {
+ // 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: CacheUntilOptions;
+
+ // Original test function that returns result with expiry
+ let testFunction: () => Promise>;
+ let testFunctionWithArgs: (arg1: string) => Promise>;
+
+ // Cached versions
+ let cachedTestFunction: () => Promise;
+ let cachedTestFunctionWithArgs: (arg1: string) => Promise;
+
+ // Mock current time
+ const mockNow = 1700000000000; // Fixed timestamp for testing
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(mockNow);
+
+ // Reset mocks for each test
+ actualExecutionSpy = jest.fn().mockResolvedValue({
+ result: 'test',
+ expiresAt: mockNow + 60000, // Expires in 60 seconds
+ });
+
+ // Create a mock cache
+ cache = {
+ get: jest.fn().mockResolvedValue(undefined),
+ set: jest.fn().mockResolvedValue(undefined),
+ } as unknown as ICache;
+
+ // Define common cache options
+ cacheOptions = {
+ functionName: 'testFunction',
+ };
+
+ // Define original functions
+ testFunction = async () => actualExecutionSpy();
+ testFunctionWithArgs = async (arg1: string) => actualExecutionSpy(arg1);
+
+ // Create cached versions
+ cachedTestFunction = useCacheUntil(testFunction, cache, {
+ ...cacheOptions,
+ functionName: 'testFunction',
+ });
+
+ cachedTestFunctionWithArgs = useCacheUntil(testFunctionWithArgs, cache, {
+ ...cacheOptions,
+ functionName: 'testFunctionWithArgs',
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('when the data is not cached', () => {
+ it('caches the result with TTL calculated from expiresAt', async () => {
+ // No cached data
+ jest.spyOn(cache, 'get').mockResolvedValue(undefined);
+
+ const result = await cachedTestFunction();
+
+ expect(result).toBe('test');
+ expect(actualExecutionSpy).toHaveBeenCalledTimes(1);
+ // TTL should be expiresAt - now = 60000
+ expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 60000);
+ });
+
+ it('uses zero TTL when expiresAt is in the past', async () => {
+ actualExecutionSpy.mockResolvedValue({
+ result: 'test',
+ expiresAt: mockNow - 1000, // Already expired
+ });
+
+ const result = await cachedTestFunction();
+
+ expect(result).toBe('test');
+ // TTL should be 0 when expiresAt is in the past
+ expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 0);
+ });
+ });
+
+ describe('when the data is cached and not expired', () => {
+ it('returns the cached result without calling the function', async () => {
+ // First call to populate the cache and expiry map
+ jest.spyOn(cache, 'get').mockResolvedValue(undefined);
+ await cachedTestFunction();
+
+ // Reset mocks
+ actualExecutionSpy.mockClear();
+ jest.spyOn(cache, 'get').mockResolvedValue('cached-test');
+
+ // Second call within expiry period
+ const result = await cachedTestFunction();
+
+ expect(result).toBe('cached-test');
+ expect(actualExecutionSpy).not.toHaveBeenCalled();
+ expect(cache.set).toHaveBeenCalledTimes(1); // Only from first call
+ });
+ });
+
+ describe('when the data is cached but expired', () => {
+ it('fetches fresh data after expiry time has passed', async () => {
+ // First call to populate the cache
+ jest.spyOn(cache, 'get').mockResolvedValue(undefined);
+ await cachedTestFunction();
+
+ // Advance time past the expiry
+ jest.setSystemTime(mockNow + 70000); // 70 seconds later
+
+ // Reset mocks for second call
+ actualExecutionSpy.mockClear();
+ actualExecutionSpy.mockResolvedValue({
+ result: 'fresh-test',
+ expiresAt: mockNow + 70000 + 60000, // New expiry
+ });
+
+ const result = await cachedTestFunction();
+
+ expect(result).toBe('fresh-test');
+ expect(actualExecutionSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('cache key generation', () => {
+ it('generates cache key with function name and arguments', async () => {
+ jest.spyOn(cache, 'get').mockResolvedValue(undefined);
+ actualExecutionSpy.mockResolvedValue({
+ result: 'test with args',
+ expiresAt: mockNow + 60000,
+ });
+
+ await cachedTestFunctionWithArgs('hello');
+
+ expect(cache.set).toHaveBeenCalledWith(
+ 'testFunctionWithArgs:"hello"',
+ 'test with args',
+ 60000,
+ );
+ });
+
+ it('uses a custom key generator if provided', async () => {
+ const customKeyGenerator = jest.fn().mockReturnValue('custom-key');
+
+ const customCachedFunction = useCacheUntil(testFunction, cache, {
+ ...cacheOptions,
+ generateCacheKey: customKeyGenerator,
+ });
+
+ await customCachedFunction();
+
+ expect(customKeyGenerator).toHaveBeenCalledTimes(1);
+ expect(cache.set).toHaveBeenCalledWith('custom-key', 'test', 60000);
+ });
+ });
+
+ describe('error handling', () => {
+ it('propagates 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('handles cache get errors gracefully', async () => {
+ // First call to populate expiry map
+ jest.spyOn(cache, 'get').mockResolvedValue(undefined);
+ await cachedTestFunction();
+
+ // Reset for second call
+ actualExecutionSpy.mockClear();
+ jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error'));
+ actualExecutionSpy.mockResolvedValue({
+ result: 'test',
+ expiresAt: mockNow + 60000,
+ });
+
+ const result = await cachedTestFunction();
+
+ expect(result).toBe('test');
+ expect(actualExecutionSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles cache set errors gracefully', async () => {
+ jest.spyOn(cache, 'get').mockResolvedValue(undefined);
+ jest
+ .spyOn(cache, 'set')
+ .mockRejectedValueOnce(new Error('Cache set error'));
+
+ const result = await cachedTestFunction();
+
+ expect(result).toBe('test');
+ expect(actualExecutionSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('anonymous functions', () => {
+ it('handles anonymous functions with a default name', async () => {
+ const anonymousFunction = async (): Promise> =>
+ actualExecutionSpy();
+ Object.defineProperty(anonymousFunction, 'name', { value: null });
+
+ const cachedAnonymousFunction = useCacheUntil(anonymousFunction, cache, {
+ // No functionName provided
+ });
+
+ await cachedAnonymousFunction();
+
+ expect(cache.set).toHaveBeenCalledWith(
+ 'anonymousFunction:',
+ 'test',
+ 60000,
+ );
+ });
+ });
+
+ describe('function name override', () => {
+ it('uses the provided function name if given', async () => {
+ const cachedWithCustomName = useCacheUntil(testFunction, cache, {
+ functionName: 'customFunctionName',
+ });
+
+ await cachedWithCustomName();
+
+ expect(cache.set).toHaveBeenCalledWith(
+ 'customFunctionName:',
+ 'test',
+ 60000,
+ );
+ });
+ });
+
+ describe('falsy but valid cache values', () => {
+ it('handles falsy but valid cache values (false, 0, empty string)', async () => {
+ // First call to populate expiry map with false result
+ actualExecutionSpy.mockResolvedValue({
+ result: false,
+ expiresAt: mockNow + 60000,
+ });
+ await cachedTestFunction();
+
+ // Reset and set cache to return false
+ actualExecutionSpy.mockClear();
+ jest.spyOn(cache, 'get').mockResolvedValue(false);
+
+ const result = await cachedTestFunction();
+
+ expect(result).toBe(false);
+ expect(actualExecutionSpy).not.toHaveBeenCalled();
+ });
+
+ it('executes the function when cache returns undefined', async () => {
+ // First call to populate expiry map
+ await cachedTestFunction();
+
+ // Reset for second call with undefined cache
+ actualExecutionSpy.mockClear();
+ jest.spyOn(cache, 'get').mockResolvedValue(undefined);
+ actualExecutionSpy.mockResolvedValue({
+ result: 'fresh',
+ expiresAt: mockNow + 60000,
+ });
+
+ const result = await cachedTestFunction();
+
+ expect(result).toBe('fresh');
+ expect(actualExecutionSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('maintenance-aligned caching scenario', () => {
+ it('caches until exact maintenance time and refetches after', async () => {
+ const maintenanceTime = mockNow + 6 * 60 * 60 * 1000; // 6 hours from now
+
+ actualExecutionSpy.mockResolvedValue({
+ result: { energyFee: 420, transactionFee: 1000 },
+ expiresAt: maintenanceTime,
+ });
+
+ // First call - should fetch and cache
+ await cachedTestFunction();
+
+ expect(cache.set).toHaveBeenCalledWith(
+ 'testFunction:',
+ { energyFee: 420, transactionFee: 1000 },
+ 6 * 60 * 60 * 1000, // 6 hours TTL
+ );
+
+ // Advance time to just before maintenance
+ jest.setSystemTime(maintenanceTime - 1000);
+ actualExecutionSpy.mockClear();
+ jest
+ .spyOn(cache, 'get')
+ .mockResolvedValue({ energyFee: 420, transactionFee: 1000 });
+
+ await cachedTestFunction();
+ expect(actualExecutionSpy).not.toHaveBeenCalled(); // Still using cache
+
+ // Advance time past maintenance
+ jest.setSystemTime(maintenanceTime + 1000);
+ actualExecutionSpy.mockResolvedValue({
+ result: { energyFee: 500, transactionFee: 1200 }, // New values
+ expiresAt: maintenanceTime + 6 * 60 * 60 * 1000, // Next maintenance
+ });
+
+ const freshResult = await cachedTestFunction();
+
+ expect(actualExecutionSpy).toHaveBeenCalledTimes(1); // Fetched fresh
+ expect(freshResult).toStrictEqual({
+ energyFee: 500,
+ transactionFee: 1200,
+ });
+ });
+ });
+});
diff --git a/merged-packages/tron-wallet-snap/src/caching/useCacheUntil.ts b/merged-packages/tron-wallet-snap/src/caching/useCacheUntil.ts
new file mode 100644
index 00000000..a41dcfe9
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/caching/useCacheUntil.ts
@@ -0,0 +1,114 @@
+import type { ICache } from './ICache';
+import logger from '../utils/logger';
+import type { Serializable } from '../utils/serialization/types';
+
+/**
+ * Result type for functions that provide their own expiry time.
+ */
+export type ResultWithExpiry = {
+ result: TResult;
+ expiresAt: number; // Unix timestamp in milliseconds
+};
+
+/**
+ * Options for configuring the caching behavior of a function with dynamic expiry.
+ */
+export type CacheUntilOptions = {
+ /**
+ * 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.
+ */
+ // TODO: Replace `any` with type
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ 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.
+ */
+// TODO: Replace `any` with type
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const defaultGenerateCacheKey = (functionName: string, args: any[]): string =>
+ `${functionName}:${args.map((arg) => JSON.stringify(arg)).join(':')}`;
+
+/**
+ * Wraps an async function with caching behavior where expiry is determined
+ * by the function result itself (dynamic TTL).
+ *
+ * Unlike `useCache` which uses a fixed TTL, this utility allows the wrapped
+ * function to specify when its result expires. This is useful for caching
+ * data that has known invalidation points (e.g., blockchain maintenance periods).
+ *
+ * @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.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 useCacheUntil = <
+ // TODO: Replace `any` with type
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ TArgs extends any[],
+ TResult extends Serializable,
+>(
+ fn: (...args: TArgs) => Promise>,
+ cache: ICache,
+ { functionName, generateCacheKey }: CacheUntilOptions,
+): ((...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';
+
+ // Map to track expiry timestamps for each cache key
+ const expiryMap = new Map();
+
+ return async (...args: TArgs): Promise => {
+ const cacheKey = _generateCacheKey(_functionName, args);
+ const now = Date.now();
+
+ // Check if cached and not expired
+ const expiresAt = expiryMap.get(cacheKey);
+ if (expiresAt !== undefined && now < expiresAt) {
+ 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
+ logger.error(`Cache get error for key "${cacheKey}":`, error);
+ }
+ }
+
+ // Execute the original function to get result and new expiry
+ const { result, expiresAt: newExpiresAt } = await fn(...args);
+
+ // Calculate TTL from expiry timestamp
+ const ttlMilliseconds = Math.max(0, newExpiresAt - now);
+
+ // Store result in cache with calculated TTL
+ await cache.set(cacheKey, result, ttlMilliseconds).catch((error) => {
+ logger.error(`Cache set error for key "${cacheKey}":`, error);
+ });
+
+ // Store expiry timestamp
+ expiryMap.set(cacheKey, newExpiresAt);
+
+ 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..0f4bdd05
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.test.ts
@@ -0,0 +1,433 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+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 { ConfigProvider } from '../../services/config';
+import { mockLogger } from '../../utils/mockLogger';
+import type { Serializable } from '../../utils/serialization/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.TrxMainnet]: {
+ id: 'tron',
+ price: 0.123456789,
+ marketCap: 1234567890.123456,
+ allTimeHigh: 0.5,
+ allTimeLow: 0.01,
+ totalVolume: 123456789.123456,
+ high1d: 0.13,
+ low1d: 0.12,
+ circulatingSupply: 1000000000,
+ dilutedMarketCap: 1234567890.123456,
+ 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', () => {
+ it('fetches multiple spot prices successfully', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: jest.fn().mockResolvedValueOnce(mockResponse),
+ });
+
+ const result = await client.getMultipleSpotPrices([
+ KnownCaip19Id.TrxMainnet,
+ KnownCaip19Id.UsdtMainnet,
+ ]);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '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);
+ });
+
+ it('logs and throws an error if fetch fails', async () => {
+ const mockError = new Error('Fetch failed');
+ mockFetch.mockRejectedValueOnce(mockError);
+
+ await expect(
+ client.getMultipleSpotPrices([
+ KnownCaip19Id.TrxMainnet,
+ KnownCaip19Id.UsdtMainnet,
+ ]),
+ ).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.TrxMainnet,
+ KnownCaip19Id.UsdtMainnet,
+ ]),
+ ).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(mockResponse),
+ });
+
+ await client.getMultipleSpotPrices(
+ [KnownCaip19Id.TrxMainnet, KnownCaip19Id.UsdtMainnet],
+ 'eur',
+ );
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://some-mock-url.com/v3/spot-prices?vsCurrency=eur&assetIds=tron%3A728126428%2Fslip44%3A195%2Ctron%3A728126428%2Ftrc20%3ATR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&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.TrxMainnet,
+ KnownCaip19Id.UsdtMainnet,
+ ]),
+ ).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.TrxMainnet,
+ KnownCaip19Id.UsdtMainnet,
+ ]),
+ ).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.TrxMainnet]!.price = -999; // Price must be a positive number
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: jest.fn().mockResolvedValueOnce(mockMalformedResponse),
+ });
+
+ await expect(
+ client.getMultipleSpotPrices([KnownCaip19Id.TrxMainnet]),
+ ).rejects.toThrow(
+ 'At path: tron:728126428/slip44:195.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: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.TrxMainnet,
+ KnownCaip19Id.UsdtMainnet,
+ ]);
+
+ 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: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.UsdtMainnet]:
+ mockResponse[KnownCaip19Id.UsdtMainnet]!,
+ }),
+ });
+
+ const result = await client.getMultipleSpotPrices([
+ 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=tron%3A728126428%2Ftrc20%3ATR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&includeMarketData=true',
+ );
+
+ // The second token should be added to the cache
+ expect(mockCache.mset).toHaveBeenCalledWith([
+ {
+ key: 'PriceApiClient:getMultipleSpotPrices:tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t:usd',
+ value: mockResponse[KnownCaip19Id.UsdtMainnet]!,
+ 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.TrxMainnet,
+ '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.TrxMainnet],
+ '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.TrxMainnet]);
+
+ // 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.TrxMainnet],
+ '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.TrxMainnet,
+ timePeriod: '5d',
+ from: 123,
+ to: 456,
+ vsCurrency: 'usd',
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '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":"tron:728126428/slip44:195","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.TrxMainnet,
+ 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..4eeb7151
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts
@@ -0,0 +1,333 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import type { CaipAssetType } from '@metamask/keyring-api';
+import { array, assert } from '@metamask/superstruct';
+import { CaipAssetTypeStruct } from '@metamask/utils';
+import { mapKeys } from 'lodash';
+
+import type {
+ FiatExchangeRatesResponse,
+ GetHistoricalPricesParams,
+ GetHistoricalPricesResponse,
+ SpotPrices,
+ VsCurrencyParam,
+} from './types';
+import {
+ FiatExchangeRatesResponseStruct,
+ GetHistoricalPricesParamsStruct,
+ GetHistoricalPricesResponseStruct,
+ SpotPricesStruct,
+ VsCurrencyParamStruct,
+} from './types';
+import type { ICache } from '../../caching/ICache';
+import { useCache } from '../../caching/useCache';
+import { SPECIAL_ASSETS } from '../../constants';
+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;
+
+ 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 url = buildUrl({
+ baseUrl: this.#baseUrl,
+ path: '/v1/exchange-rates/fiat',
+ });
+
+ const response = await this.#fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ assert(data, FiatExchangeRatesResponseStruct);
+
+ 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 {
+ if (tokenCaipAssetTypes.length === 0) {
+ return {};
+ }
+
+ const uniqueTokenCaipAssetTypes = [...new Set(tokenCaipAssetTypes)];
+
+ // Split uniqueTokenCaipAssetTypes into chunks
+ const chunks: CaipAssetType[][] = [];
+ for (
+ let index = 0;
+ index < uniqueTokenCaipAssetTypes.length;
+ index += this.#chunkSize
+ ) {
+ chunks.push(
+ uniqueTokenCaipAssetTypes.slice(index, index + 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): string =>
+ `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`;
+
+ // Parses back the cache key
+ const parseCacheKey = (key: string): RegExpMatchArray => {
+ 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,
+ (_unused, 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 {
+ assert(tokenCaip19Types, array(CaipAssetTypeStruct));
+ assert(vsCurrency, VsCurrencyParamStruct);
+
+ const filteredTokens = tokenCaip19Types.filter(
+ (tokenCaip19Type) => !SPECIAL_ASSETS.includes(tokenCaip19Type),
+ );
+
+ return this.#getMultipleSpotPrices_CACHE(filteredTokens, 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 {
+ 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 }),
+ },
+ encodePathParams: false,
+ });
+
+ 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 {
+ assert(params, GetHistoricalPricesParamsStruct);
+
+ 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/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/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..08fec109
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/price-api/types.ts
@@ -0,0 +1,248 @@
+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;
+
+/**
+ * Struct for validating exchange rate data from the API.
+ * Includes bounds validation to prevent malicious data injection.
+ */
+export const ExchangeRateStruct = object({
+ name: string(),
+ ticker: TickerStruct,
+ value: min(number(), 0),
+ currencyType: enums(['fiat', 'crypto', 'commodity']),
+});
+
+export type ExchangeRate = Infer;
+
+/**
+ * Struct for validating the fiat exchange rates response.
+ * Maps ticker symbols to their exchange rate data.
+ * Despite the endpoint name, the response includes all exchange rates (crypto, fiat, commodity).
+ */
+export const FiatExchangeRatesResponseStruct = record(
+ TickerStruct,
+ ExchangeRateStruct,
+);
+
+export type FiatExchangeRatesResponse = Infer<
+ typeof FiatExchangeRatesResponseStruct
+>;
+
+/**
+ * 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/clients/security-alerts-api/SecurityAlertsApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/SecurityAlertsApiClient.ts
new file mode 100644
index 00000000..485abede
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/SecurityAlertsApiClient.ts
@@ -0,0 +1,144 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import { assert } from '@metamask/superstruct';
+import { Types } from 'tronweb';
+
+import {
+ SecurityAlertResponseStruct,
+ type SecurityAlertSimulationValidationResponse,
+} from './structs';
+import { extractScanParametersFromTransactionData } from './utils';
+import type { ConfigProvider } from '../../services/config';
+import { createPrefixedLogger, type ILogger } from '../../utils/logger';
+import { isTransactionWellFormed } from '../../validation/transaction';
+
+/**
+ * Client for interacting with the Security Alerts API for security scanning.
+ *
+ * @example
+ * ```typescript
+ * const client = new SecurityAlertsApiClient(configProvider, logger);
+ * ```
+ */
+export class SecurityAlertsApiClient {
+ /**
+ * Contract types for which the Security Alerts API can produce reliable
+ * simulation results.
+ */
+ static readonly SUPPORTED_CONTRACT_TYPES: Types.ContractType[] = [
+ Types.ContractType.TransferContract,
+ Types.ContractType.CreateSmartContract,
+ Types.ContractType.TriggerSmartContract,
+ ];
+
+ /**
+ * Checks whether the first contract type in the transaction is supported
+ * by the Security Alerts API simulation.
+ *
+ * @param rawData - The raw transaction data.
+ * @returns True if the contract type is supported for simulation.
+ */
+ static isContractTypeSupported(
+ rawData: Types.Transaction['raw_data'],
+ ): boolean {
+ const [contractInteraction] = rawData.contract;
+ if (!contractInteraction) {
+ return false;
+ }
+ return SecurityAlertsApiClient.SUPPORTED_CONTRACT_TYPES.includes(
+ contractInteraction.type,
+ );
+ }
+
+ readonly #fetch: typeof globalThis.fetch;
+
+ readonly #logger: ILogger;
+
+ readonly #baseUrl: string;
+
+ /**
+ * Creates a new SecurityAlertsApiClient instance.
+ *
+ * @param configProvider - The configuration provider.
+ * @param logger - Logger instance for logging.
+ */
+ constructor(configProvider: ConfigProvider, logger: ILogger) {
+ this.#fetch = fetch;
+ this.#logger = createPrefixedLogger(logger, '[🔒 SecurityAlertsApiClient]');
+ this.#baseUrl = configProvider.get().securityAlertsApi.baseUrl;
+ }
+
+ /**
+ * Scans a Tron transaction using the Security Alerts API.
+ *
+ * @param params - The parameters for the scan.
+ * @param params.accountAddress - The account address in base58 format.
+ * @param params.transactionRawData - The raw data of the transaction.
+ * @param params.origin - The origin URL of the request.
+ * @param params.options - Optional scan options (simulation, validation).
+ * @returns The security alert response from Security Alerts API.
+ */
+ async scanTransaction({
+ accountAddress,
+ transactionRawData,
+ origin,
+ options = ['simulation', 'validation'],
+ }: {
+ accountAddress: string;
+ transactionRawData: Types.Transaction['raw_data'];
+ origin: string;
+ options?: string[];
+ }): Promise {
+ this.#logger.info('Scanning Tron transaction with Security Alerts API');
+
+ if (
+ !isTransactionWellFormed(transactionRawData) ||
+ !SecurityAlertsApiClient.isContractTypeSupported(transactionRawData)
+ ) {
+ throw new Error('Transaction is not supported for scanning.');
+ }
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ accept: 'application/json',
+ };
+
+ const scanParameters =
+ extractScanParametersFromTransactionData(transactionRawData);
+
+ if (!scanParameters) {
+ throw new Error('Could not extract scan parameters from transaction.');
+ }
+
+ const response = await this.#fetch(
+ `${this.#baseUrl}/tron/transaction/scan`,
+ {
+ headers,
+ method: 'POST',
+ body: JSON.stringify({
+ account_address: accountAddress,
+ metadata: {
+ domain: origin,
+ },
+ data: scanParameters,
+ options,
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const errorBody = await response.text();
+ this.#logger.error(
+ `Security Alerts API error: ${response.status} - ${errorBody}`,
+ );
+ throw new Error(
+ `Security Alerts API error: ${response.status} - ${errorBody}`,
+ );
+ }
+
+ const data = await response.json();
+ assert(data, SecurityAlertResponseStruct);
+
+ return data;
+ }
+}
diff --git a/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/structs.ts b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/structs.ts
new file mode 100644
index 00000000..b627dc85
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/structs.ts
@@ -0,0 +1,114 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type { Infer } from '@metamask/superstruct';
+import {
+ any,
+ array,
+ boolean,
+ enums,
+ nullable,
+ number,
+ optional,
+ string,
+ type,
+ union,
+} from '@metamask/superstruct';
+
+export const FungibleAssetChangeStruct = type({
+ usd_price: optional(string()),
+ summary: optional(string()),
+ value: optional(string()),
+ raw_value: string(),
+});
+
+export const Erc721AssetChangeStruct = type({
+ summary: optional(string()),
+ token_id: string(),
+ arbitrary_collection_token: boolean(),
+ logo_url: optional(nullable(string())),
+ usd_price: optional(string()),
+});
+
+export const Erc1155AssetChangeStruct = type({
+ summary: optional(string()),
+ token_id: string(),
+ value: string(),
+ arbitrary_collection_token: boolean(),
+ logo_url: optional(nullable(string())),
+ usd_price: optional(string()),
+});
+
+export const AssetChangeStruct = union([
+ FungibleAssetChangeStruct,
+ Erc721AssetChangeStruct,
+ Erc1155AssetChangeStruct,
+]);
+
+export const AssetStruct = type({
+ type: string(),
+ symbol: optional(string()),
+ name: optional(string()),
+ logo_url: optional(nullable(string())),
+ address: optional(string()),
+ decimals: optional(number()),
+});
+
+export const AssetDiffStruct = type({
+ asset_type: string(),
+ asset: AssetStruct,
+ in: array(AssetChangeStruct),
+ out: array(AssetChangeStruct),
+});
+
+export const AccountSummaryStruct = type({
+ assets_diffs: array(AssetDiffStruct),
+});
+
+export const SimulationErrorDetailsStruct = type({
+ code: string(),
+ category: string(),
+});
+
+export const TransactionErrorDetailsStruct = type({
+ type: string(),
+ message: string(),
+ transaction_index: number(),
+});
+
+export const SimulationStruct = type({
+ status: enums(['Success', 'Error']),
+ error: optional(string()),
+ error_details: optional(
+ union([SimulationErrorDetailsStruct, TransactionErrorDetailsStruct]),
+ ),
+ account_summary: optional(AccountSummaryStruct),
+});
+
+export const ValidationStruct = type({
+ status: enums(['Success', 'Error']),
+ result_type: enums(['Benign', 'Warning', 'Malicious', 'Error']),
+ error: optional(string()),
+ description: optional(string()),
+ reason: optional(string()),
+ classification: optional(string()),
+
+ features: optional(array(any())),
+});
+
+export const SecurityAlertResponseStruct = type({
+ validation: ValidationStruct,
+ simulation: SimulationStruct,
+});
+
+export type AssetChange = Infer;
+export type Asset = Infer;
+export type AssetDiff = Infer;
+export type AccountSummary = Infer;
+export type SimulationErrorDetails = Infer;
+export type TransactionErrorDetails = Infer<
+ typeof TransactionErrorDetailsStruct
+>;
+export type Simulation = Infer;
+export type Validation = Infer;
+export type SecurityAlertSimulationValidationResponse = Infer<
+ typeof SecurityAlertResponseStruct
+>;
diff --git a/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/types.ts b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/types.ts
new file mode 100644
index 00000000..bcbe052c
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/types.ts
@@ -0,0 +1,6 @@
+export type SecurityScanPayload = {
+ from: string | null;
+ to: string | null;
+ data: string | null;
+ value: number | null;
+};
diff --git a/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/utils.test.ts b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/utils.test.ts
new file mode 100644
index 00000000..008da943
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/utils.test.ts
@@ -0,0 +1,124 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { Types } from 'tronweb';
+
+import { extractScanParametersFromTransactionData } from './utils';
+import type {
+ TransferAssetContractParameter,
+ TransferContractParameter,
+} from '../trongrid/types';
+
+describe('SecurityAlertsApiClient utils', () => {
+ describe('extractScanParametersFromTransactionData', () => {
+ it('extracts scan parameters from a TransferAssetContractParameter', () => {
+ const contractInteraction: TransferAssetContractParameter = {
+ type_url: 'type.googleapis.com/protocol.TransferAssetContract',
+ value: {
+ asset_name: 'MyToken',
+ owner_address: '41a614f803b6fd780986a42c78ec9c7f77e6ded13c',
+ to_address: '4191bba2f3f6e1c4d5c8e8f5b6a7c8d9e0f1a2b3c4',
+ amount: 1000000,
+ },
+ };
+ const rawData: Types.Transaction['raw_data'] = {
+ contract: [
+ {
+ type: Types.ContractType.TransferAssetContract,
+ parameter: contractInteraction,
+ },
+ ],
+ ref_block_bytes: '',
+ ref_block_hash: '',
+ expiration: 0,
+ timestamp: 0,
+ };
+
+ const result = extractScanParametersFromTransactionData(rawData);
+
+ expect(result).toStrictEqual({
+ from: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ to: 'TPFmm695uHPTn8wNmQbF8yMiZxfCeUdXkJ',
+ data: null,
+ value: 1000000,
+ });
+ });
+
+ it('extracts scan parameters from a TransferContractParameter', () => {
+ const contractInteraction: TransferContractParameter = {
+ type_url: 'type.googleapis.com/protocol.TransferContract',
+ value: {
+ owner_address: '41a614f803b6fd780986a42c78ec9c7f77e6ded13c',
+ to_address: '4191bba2f3f6e1c4d5c8e8f5b6a7c8d9e0f1a2b3c4',
+ amount: 500000,
+ },
+ };
+ const rawData: Types.Transaction['raw_data'] = {
+ contract: [
+ {
+ type: Types.ContractType.TransferContract,
+ parameter: contractInteraction,
+ },
+ ],
+ ref_block_bytes: '',
+ ref_block_hash: '',
+ expiration: 0,
+ timestamp: 0,
+ };
+
+ const result = extractScanParametersFromTransactionData(rawData);
+
+ expect(result).toStrictEqual({
+ from: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ to: 'TPFmm695uHPTn8wNmQbF8yMiZxfCeUdXkJ',
+ data: null,
+ value: 500000,
+ });
+ });
+
+ it('extract scan parameters from a TriggerSmartContract', () => {
+ const contractInteraction = {
+ type_url: 'type.googleapis.com/protocol.TriggerSmartContract',
+ value: {
+ owner_address: '41a614f803b6fd780986a42c78ec9c7f77e6ded13c',
+ contract_address: '4191bba2f3f6e1c4d5c8e8f5b6a7c8d9e0f1a2b3c4',
+ call_value: 200000,
+ data: 'abcdef',
+ },
+ };
+ const rawData: Types.Transaction['raw_data'] = {
+ contract: [
+ {
+ type: Types.ContractType.TriggerSmartContract,
+ parameter: contractInteraction,
+ },
+ ],
+ ref_block_bytes: '',
+ ref_block_hash: '',
+ expiration: 0,
+ timestamp: 0,
+ };
+
+ const result = extractScanParametersFromTransactionData(rawData);
+
+ expect(result).toStrictEqual({
+ from: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ to: 'TPFmm695uHPTn8wNmQbF8yMiZxfCeUdXkJ',
+ data: '0xabcdef',
+ value: 200000,
+ });
+ });
+
+ it('returns null if no contract parameter is found', () => {
+ const rawData: Types.Transaction['raw_data'] = {
+ contract: [],
+ ref_block_bytes: '',
+ ref_block_hash: '',
+ expiration: 0,
+ timestamp: 0,
+ };
+
+ const result = extractScanParametersFromTransactionData(rawData);
+
+ expect(result).toBeNull();
+ });
+ });
+});
diff --git a/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts
new file mode 100644
index 00000000..99fef6bb
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts
@@ -0,0 +1,125 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { add0x } from '@metamask/utils';
+import { TronWeb, Types } from 'tronweb';
+
+import type { SecurityScanPayload } from './types';
+
+/**
+ * Extracts scan parameters from the raw transaction data. This function
+ * can be used as adapter between a Tron transaction and the payload
+ * supported by SecurityAlertsApiClient.
+ *
+ * Only the first contract in `raw_data.contract` is used because
+ * the Tron protocol currently only executes one contract per
+ * transaction. The array exists in TronWeb's type definition for
+ * forward-compatibility, but any transaction with more than one
+ * contract is considered malformed.
+ *
+ * @see https://developers.tron.network/docs/tron-protocol-transaction
+ * @param rawData - The raw transaction data.
+ * @returns The extracted scan parameters.
+ */
+export function extractScanParametersFromTransactionData(
+ rawData: Types.Transaction['raw_data'],
+): SecurityScanPayload | null {
+ const contractParam = rawData.contract[0]?.parameter.value;
+
+ if (!contractParam) {
+ return null;
+ }
+
+ const from = TronWeb.address.fromHex(contractParam.owner_address);
+
+ let to = '';
+ if ('contract_address' in contractParam && contractParam.contract_address) {
+ to = TronWeb.address.fromHex(contractParam.contract_address);
+ } else if ('to_address' in contractParam && contractParam.to_address) {
+ to = TronWeb.address.fromHex(contractParam.to_address);
+ }
+
+ let value = 0;
+ if ('call_value' in contractParam && contractParam.call_value) {
+ value = contractParam.call_value;
+ } else if ('amount' in contractParam && contractParam.amount) {
+ value = contractParam.amount;
+ }
+
+ let data: string | null = null;
+ if ('data' in contractParam && contractParam.data) {
+ data = add0x(contractParam.data);
+ }
+
+ return { from, to, data, value };
+}
+
+/**
+ * Builds a minimal `Transaction['raw_data']` suitable for security scanning
+ * from high-level send parameters. This is the inverse of
+ * {@link extractScanParametersFromTransactionData}.
+ *
+ * @param params - The send parameters.
+ * @param params.from - The sender address (base58).
+ * @param params.to - The recipient or contract address (base58).
+ * @param params.amount - The amount in sun (TRX value for the transaction).
+ * @param params.data - Optional contract call data.
+ * @param params.contractType - The Tron contract type to build.
+ * @returns A minimal raw transaction data object.
+ */
+export function buildTransactionRawData({
+ from,
+ to,
+ amount,
+ data,
+ contractType,
+}: {
+ from: string;
+ to: string;
+ amount: number;
+ data?: string | null;
+ contractType: Types.ContractType;
+}): Types.Transaction['raw_data'] {
+ const ownerAddressHex = TronWeb.address.toHex(from);
+
+ if (contractType === Types.ContractType.TriggerSmartContract) {
+ return {
+ contract: [
+ {
+ type: Types.ContractType.TriggerSmartContract,
+ parameter: {
+ value: {
+ owner_address: ownerAddressHex,
+ contract_address: TronWeb.address.toHex(to),
+ ...(data ? { data } : {}),
+ ...(amount ? { call_value: amount } : {}),
+ },
+ type_url: 'type.googleapis.com/protocol.TriggerSmartContract',
+ },
+ },
+ ],
+ ref_block_bytes: '',
+ ref_block_hash: '',
+ expiration: 0,
+ timestamp: 0,
+ };
+ }
+
+ return {
+ contract: [
+ {
+ type: Types.ContractType.TransferContract,
+ parameter: {
+ value: {
+ owner_address: ownerAddressHex,
+ to_address: TronWeb.address.toHex(to),
+ amount,
+ },
+ type_url: 'type.googleapis.com/protocol.TransferContract',
+ },
+ },
+ ],
+ ref_block_bytes: '',
+ ref_block_hash: '',
+ expiration: 0,
+ timestamp: 0,
+ };
+}
diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts
new file mode 100644
index 00000000..e44ba815
--- /dev/null
+++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts
@@ -0,0 +1,165 @@
+import { SnapClient } from './SnapClient';
+import type { ILogger } from '../../utils/logger';
+
+// Mock the global snap object
+const mockSnapRequest = jest.fn();
+(globalThis as any).snap = {
+ request: mockSnapRequest,
+};
+
+/**
+ * Creates a fresh SnapClient and executes the given test function with it.
+ * Resets the `snap.request` mock before each invocation.
+ *
+ * @param testFn - The test body receiving the client and mock.
+ * @returns Whatever the test function returns.
+ */
+async function withSnapClient(
+ testFn: (setup: {
+ snapClient: SnapClient;
+ mockSnapRequest: jest.Mock;
+ mockLogger: jest.Mocked;
+ }) => void | Promise,
+) {
+ mockSnapRequest.mockReset();
+ const mockLogger = {
+ log: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ } as unknown as jest.Mocked;
+ const snapClient = new SnapClient({ logger: mockLogger });
+ await testFn({ snapClient, mockSnapRequest, mockLogger });
+}
+
+describe('SnapClient', () => {
+ describe('getInterfaceContext', () => {
+ it('returns context when interface exists', async () => {
+ await withSnapClient(
+ async ({ snapClient, mockSnapRequest: mockRequest }) => {
+ const mockContext = { foo: 'bar' };
+ mockRequest.mockResolvedValue(mockContext);
+
+ const result = await snapClient.getInterfaceContext('test-id');
+
+ expect(result).toStrictEqual(mockContext);
+ expect(mockRequest).toHaveBeenCalledWith({
+ method: 'snap_getInterfaceContext',
+ params: { id: 'test-id' },
+ });
+ },
+ );
+ });
+
+ it('returns null when rawContext is falsy', async () => {
+ await withSnapClient(
+ async ({ snapClient, mockSnapRequest: mockRequest }) => {
+ mockRequest.mockResolvedValue(null);
+
+ const result = await snapClient.getInterfaceContext('test-id');
+
+ expect(result).toBeNull();
+ },
+ );
+ });
+ });
+
+ describe('updateInterface', () => {
+ it('returns the update result on success', async () => {
+ await withSnapClient(
+ async ({ snapClient, mockSnapRequest: mockRequest }) => {
+ mockRequest.mockResolvedValue(null);
+
+ const result = await snapClient.updateInterface(
+ 'test-id',
+ '