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', + '
test
', + { context: 'data' }, + ); + + expect(result).toBeNull(); + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_updateInterface', + params: { + id: 'test-id', + ui: '
test
', + context: { context: 'data' }, + }, + }); + }, + ); + }); + }); + + describe('trackError', () => { + it('returns the Sentry event ID and forwards the serialized error', async () => { + await withSnapClient( + async ({ snapClient, mockSnapRequest: mockRequest, mockLogger }) => { + mockRequest.mockResolvedValue('evt_abc123'); + const error = new Error('boom'); + error.name = 'BoomError'; + + const result = await snapClient.trackError(error); + + expect(result).toBe('evt_abc123'); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + name: 'BoomError', + message: 'boom', + cause: null, + }), + }, + }); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }, + ); + }); + + it('swallows RPC failures and logs a warning', async () => { + await withSnapClient( + async ({ snapClient, mockSnapRequest: mockRequest, mockLogger }) => { + const rpcError = new Error('rpc down'); + mockRequest.mockRejectedValue(rpcError); + + const result = await snapClient.trackError(new Error('x')); + + expect(result).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ rpcError }), + expect.stringContaining('Failed to track error'), + ); + }, + ); + }); + + it('serializes the error cause recursively', async () => { + await withSnapClient( + async ({ snapClient, mockSnapRequest: mockRequest }) => { + mockRequest.mockResolvedValue('evt_xyz'); + const inner = new Error('inner'); + const outer = new Error('outer', { cause: inner }); + + await snapClient.trackError(outer); + + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: 'outer', + cause: expect.objectContaining({ + name: 'Error', + message: 'inner', + }), + }), + }, + }); + }, + ); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts new file mode 100644 index 00000000..112025b5 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/snap/SnapClient.ts @@ -0,0 +1,436 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { JsonSLIP10Node } from '@metamask/key-tree'; +import type { EntropySourceId } from '@metamask/keyring-api'; +import { + getJsonError, + type DialogResult, + type EntropySource, + type GetClientStatusResult, + type Json, + type ResolveInterfaceResult, + type UpdateInterfaceResult, +} from '@metamask/snaps-sdk'; + +import { SecurityEventType, TransactionEventType } from '../../types/analytics'; +import type { Preferences } from '../../types/snap'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; + +/** + * Client for interacting with the Snap API. + * Provides methods for managing interfaces, dialogs, preferences, and background events. + */ +export class SnapClient { + readonly #logger: ILogger; + + constructor({ logger }: { logger: ILogger }) { + this.#logger = createPrefixedLogger(logger, '[📡 SnapClient]'); + } + + /** + * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. + * + * @param params - The parameters for the Solana key derivation. + * @param params.entropySource - The entropy source to use for key derivation. + * @param params.path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. + * @param params.curve - The elliptic curve to use for key derivation. + * @returns A Promise that resolves to a `SLIP10NodeInterface` object. + */ + async getBip32Entropy({ + entropySource, + path, + curve, + }: { + entropySource?: EntropySourceId | undefined; + path: string[]; + curve: 'secp256k1' | 'ed25519'; + }): Promise { + return snap.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + ...(entropySource ? { source: entropySource } : {}), + }, + }); + } + + /** + * Create a UI interface with the provided UI component and context. + * + * @param ui - The UI component to render. + * @param context - The initial context object to associate with the interface. + * @returns The created interface id. + */ + async createInterface( + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ui: any, + context: TContext & Record, + ): Promise { + return snap.request({ + method: 'snap_createInterface', + params: { + ui, + context, + }, + }); + } + + /** + * Update an existing UI interface with a new UI component and context. + * + * @param id - The interface id returned from createInterface. + * @param ui - The new UI component to render. + * @param context - The updated context object to associate with the interface. + * @returns The update interface result. + */ + async updateInterface( + id: string, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ui: any, + context: TContext & Record, + ): Promise { + return snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui, + context, + }, + }); + } + + /** + * Gets the context of an interface by its ID. + * + * @param id - The ID for the interface. + * @returns The context object associated with the interface, or null if not found. + */ + async getInterfaceContext( + id: string, + ): Promise { + const rawContext = await snap.request({ + method: 'snap_getInterfaceContext', + params: { + id, + }, + }); + + if (!rawContext) { + return null; + } + + return rawContext as TContext; + } + + /** + * Resolve a dialog using the provided ID. + * + * @param id - The ID for the interface to update. + * @param value - The result to resolve the interface with. + * @returns An object containing the state of the interface. + */ + async resolveInterface( + id: string, + value: Json, + ): Promise { + return snap.request({ + method: 'snap_resolveInterface', + params: { + id, + value, + }, + }); + } + + /** + * Shows a dialog using the provided ID. + * + * @param id - The ID for the dialog. + * @returns A promise that resolves to a string. + */ + async showDialog(id: string): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + id, + }, + }); + } + + /** + * Get preferences from snap. + * + * @returns A promise that resolves to snap preferences. + */ + async getPreferences(): Promise { + return snap.request({ + method: 'snap_getPreferences', + }) as Promise; + } + + /** + * Retrieves the client status (locked/unlocked) in this case from MM. + * + * @returns An object containing the status. + */ + async getClientStatus(): Promise { + return snap.request({ + method: 'snap_getClientStatus', + }); + } + + /** + * Schedules a background event. + * + * @param options - The options for the background event. + * @param options.method - The method to call. + * @param options.params - The params to pass to the method. + * @param options.duration - The duration to wait before the event is scheduled. + * @returns A promise that resolves to a string. + */ + async scheduleBackgroundEvent({ + method, + params = {}, + duration, + }: { + method: string; + params?: Record; + duration: string; + }): Promise { + return snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration, + request: { + method, + params, + }, + }, + }); + } + + /** + * List all entropy sources. + * + * @returns An array of entropy sources. + */ + async listEntropySources(): Promise { + return snap.request({ + method: 'snap_listEntropySources', + }); + } + + /** + * Track an event in MetaMask analytics. + * + * @param event - The event name to track. + * @param properties - Additional properties to include with the event. + */ + async trackEvent( + event: string, + properties: Record, + ): Promise { + try { + await snap.request({ + method: 'snap_trackEvent', + params: { + event: { + event, + properties, + }, + }, + }); + } catch { + // Silently fail if tracking fails - we don't want to interrupt the user flow + } + } + + /** + * Track an error in MetaMask via Sentry (`snap_trackError`). + * + * RPC failures are caught and logged but never rethrown, so this is + * safe to call from already-failing error-handling paths without risk + * of masking the original failure. + * + * @param error - The error to report to Sentry. + * @returns The Sentry event ID on success, or `undefined` on failure. + */ + async trackError(error: Error): Promise { + try { + return await snap.request({ + method: 'snap_trackError', + params: { error: getJsonError(error) }, + }); + } catch (rpcError) { + this.#logger.warn( + { rpcError }, + 'Failed to track error via snap_trackError', + ); + return undefined; + } + } + + /** + * Track a "Transaction Added" event when a transaction confirmation is shown. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionAdded(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionAdded, { + message: 'Snap transaction added', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Transaction Rejected" event when user rejects a transaction. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionRejected(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionRejected, { + message: 'Snap transaction rejected', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Transaction Submitted" event when a transaction is successfully broadcast. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionSubmitted(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionSubmitted, { + message: 'Snap transaction submitted', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Transaction Approved" event when a transaction is approved. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionApproved(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionApproved, { + message: 'Snap transaction approved', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Transaction Finalized" event when a transaction reaches final state. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ + async trackTransactionFinalized(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + }): Promise { + await this.trackEvent(TransactionEventType.TransactionFinalized, { + message: 'Snap transaction finalized', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); + } + + /** + * Track a "Security Alert Detected" event when a malicious or warning transaction is detected. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + * @param properties.securityAlertResponse - The type of security alert (Warning, Malicious). + * @param properties.securityAlertReason - The reason for the security alert. + * @param properties.securityAlertDescription - Human-readable description of the alert. + */ + async trackSecurityAlertDetected(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + securityAlertResponse: string; + securityAlertReason: string | null; + securityAlertDescription: string; + }): Promise { + await this.trackEvent(SecurityEventType.SecurityAlertDetected, { + message: 'Snap security alert detected', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + security_alert_response: properties.securityAlertResponse, + security_alert_reason: properties.securityAlertReason, + security_alert_description: properties.securityAlertDescription, + }); + } + + /** + * Track a "Security Scan Completed" event when a transaction security scan finishes. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + * @param properties.scanStatus - The status of the scan (SUCCESS, ERROR). + * @param properties.hasSecurityAlerts - Whether security alerts were detected. + */ + async trackSecurityScanCompleted(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + scanStatus: string; + hasSecurityAlerts: boolean; + }): Promise { + await this.trackEvent(SecurityEventType.SecurityScanCompleted, { + message: 'Snap security scan completed', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + scan_status: properties.scanStatus, + has_security_alerts: properties.hasSecurityAlerts, + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts new file mode 100644 index 00000000..fdc569bd --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.test.ts @@ -0,0 +1,208 @@ +import { TokenApiClient } from './TokenApiClient'; +import { KnownCaip19Id, Network, Networks } from '../../constants'; +import type { TokenCaipAssetType } from '../../services/assets/types'; +import type { ConfigProvider } from '../../services/config'; +import { mockLogger } from '../../utils/mockLogger'; + +const MOCK_METADATA_RESPONSE = [ + { + decimals: 6, + assetId: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + name: 'Tether USD', + symbol: 'USDT', + }, + { + decimals: 18, + assetId: 'tron:728126428/trc20:TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4', + name: 'TrueUSD', + symbol: 'TUSD', + }, +]; + +describe('TokenApiClient', () => { + const mockFetch = jest.fn(); + + let client: TokenApiClient; + let mockConfigProvider: ConfigProvider; + + beforeEach(() => { + jest.clearAllMocks(); + + mockConfigProvider = { + get: jest.fn().mockReturnValue({ + tokenApi: { + baseUrl: 'https://some-mock-url.com', + chunkSize: 50, + }, + staticApi: { + baseUrl: 'https://some-mock-static-url.com', + }, + }), + } as unknown as ConfigProvider; + + client = new TokenApiClient(mockConfigProvider, mockFetch, mockLogger); + }); + + describe('constructor', () => { + it('rejects invalid baseUrl', async () => { + const invalidConfigProvider = { + get: jest.fn().mockReturnValue({ + tokenApi: { + baseUrl: 'invalid-url', + }, + }), + } as unknown as ConfigProvider; + + expect( + () => new TokenApiClient(invalidConfigProvider, mockFetch, mockLogger), + ).toThrow('Invalid URL format'); + }); + }); + + describe('getTokensMetadata', () => { + it('fetches and parses token metadata', async () => { + const tokenAddresses = [ + `${Networks[Network.Mainnet].caip2Id}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType, + `${Networks[Network.Mainnet].caip2Id}/trc20:TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4` as TokenCaipAssetType, + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_METADATA_RESPONSE), + }); + + const metadata = await client.getTokensMetadata(tokenAddresses); + + expect(metadata).toStrictEqual({ + [`tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`]: { + iconUrl: + 'https://some-mock-static-url.com/api/v2/tokenIcons/assets/tron/728126428/trc20/TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t.png', + name: 'Tether USD', + symbol: 'USDT', + fungible: true, + units: [ + { + decimals: 6, + name: 'Tether USD', + symbol: 'USDT', + }, + ], + }, + [`tron:728126428/trc20:TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4`]: { + iconUrl: + 'https://some-mock-static-url.com/api/v2/tokenIcons/assets/tron/728126428/trc20/TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4.png', + name: 'TrueUSD', + symbol: 'TUSD', + fungible: true, + units: [ + { + decimals: 18, + name: 'TrueUSD', + symbol: 'TUSD', + }, + ], + }, + }); + }); + + it('handles addresses in chunks when more than the limit is provided', async () => { + const tokenAddresses = Array.from( + { length: 60 }, + (_, i) => + `${Networks[Network.Nile].caip2Id}/trc20:address${i}` as TokenCaipAssetType, + ); + + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_METADATA_RESPONSE), + }); + + await client.getTokensMetadata(tokenAddresses); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('rejects caip19Ids that are invalid', async () => { + await expect( + client.getTokensMetadata(['invalid-caip19-id' as TokenCaipAssetType]), + ).rejects.toThrow( + 'At path: 0 -- Expected a value of type `CaipAssetType`, but received: `"invalid-caip19-id"`', + ); + }); + + it('rejects caip19Ids that include malicious inputs', async () => { + await expect( + client.getTokensMetadata([ + KnownCaip19Id.UsdtMainnet, + 'INVALID' as TokenCaipAssetType, + ]), + ).rejects.toThrow( + 'At path: 1 -- Expected a value of type `CaipAssetType`, but received: `"INVALID"`', + ); + }); + + it('throws an error if fetch fails', async () => { + const tokenAddresses = [ + `${Networks[Network.Nile].caip2Id}/trc20:address0` as TokenCaipAssetType, + `${Networks[Network.Nile].caip2Id}/trc20:address1` as TokenCaipAssetType, + ]; + + const errorMessage = 'Error fetching token metadata'; + mockFetch.mockRejectedValueOnce(new Error(errorMessage)); + + await expect(client.getTokensMetadata(tokenAddresses)).rejects.toThrow( + errorMessage, + ); + expect(mockLogger.error).toHaveBeenCalledWith( + new Error(errorMessage), + errorMessage, + ); + }); + + it('throws an error if the response includes an invalid assetId', async () => { + const tokenAddresses = [ + `${Networks[Network.Nile].caip2Id}/trc20:address0` as TokenCaipAssetType, + `${Networks[Network.Nile].caip2Id}/trc20:address1` as TokenCaipAssetType, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([ + { + decimals: 9, + assetId: 'bad-asset-id', + name: 'Popcat 1', + symbol: 'POPCAT', + }, + ]), + }); + + await expect(client.getTokensMetadata(tokenAddresses)).rejects.toThrow( + 'At path: 0.assetId -- Expected a value of type `CaipAssetType`, but received: `"bad-asset-id"`', + ); + }); + + it('returns default metadata if the asset type is not supported by the Token API', async () => { + const supportedAssetType = + `${Networks[Network.Mainnet].caip2Id}/trc10:1GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr` as TokenCaipAssetType; + const unsupportedAssetType = + `${Networks[Network.Mainnet].caip2Id}/trc10:address1` as TokenCaipAssetType; + + const tokenAddresses = [supportedAssetType, unsupportedAssetType]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([MOCK_METADATA_RESPONSE[0]]), + }); + + const metadata = await client.getTokensMetadata(tokenAddresses); + + expect(metadata[supportedAssetType]).toBeDefined(); + expect(metadata[unsupportedAssetType]).toStrictEqual({ + name: 'UNKNOWN', + symbol: 'UNKNOWN', + fungible: true, + iconUrl: '', + units: [{ name: 'UNKNOWN', symbol: 'UNKNOWN', decimals: 9 }], + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts new file mode 100644 index 00000000..68a504a2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts @@ -0,0 +1,185 @@ +import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; +import { array, assert, type Infer } from '@metamask/superstruct'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; + +import { TokenMetadataResponseStruct } from './structs'; +import { Network, SPECIAL_ASSETS } from '../../constants'; +import type { TokenCaipAssetType } from '../../services/assets/types'; +import { TokenCaipAssetTypeStruct } from '../../services/assets/types'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import type { ILogger } from '../../utils/logger'; +import logger from '../../utils/logger'; +import { UrlStruct } from '../../validation/structs'; + +const DEFAULT_DECIMALS = 9; + +const DEFAULT_TOKEN_METADATA: FungibleAssetMetadata = { + name: 'UNKNOWN', + symbol: 'UNKNOWN', + fungible: true, + iconUrl: '', + units: [{ name: 'UNKNOWN', symbol: 'UNKNOWN', decimals: DEFAULT_DECIMALS }], +} as const; + +export class TokenApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + readonly #chunkSize: number; + + readonly #tokenIconBaseUrl: string; + + public static readonly supportedNetworks = [ + Network.Mainnet, + Network.Nile, + Network.Shasta, + ]; + + constructor( + configProvider: ConfigProvider, + _fetch: typeof globalThis.fetch = globalThis.fetch, + _logger: ILogger = logger, + ) { + this.#fetch = _fetch; + this.#logger = _logger; + + const { tokenApi, staticApi } = configProvider.get(); + const { baseUrl, chunkSize } = tokenApi; + + assert(baseUrl, UrlStruct); + + this.#baseUrl = baseUrl; + this.#chunkSize = chunkSize; + this.#tokenIconBaseUrl = staticApi.baseUrl; + } + + async #fetchTokenMetadataBatch( + assetTypes: TokenCaipAssetType[], + ): Promise> { + assert(assetTypes, array(TokenCaipAssetTypeStruct)); + + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/assets', + queryParams: { + assetIds: assetTypes.join(','), + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + assert(data, TokenMetadataResponseStruct); + + return data; + } + + async getTokensMetadata( + assetTypes: TokenCaipAssetType[], + ): Promise> { + try { + assert(assetTypes, array(CaipAssetTypeStruct)); + + /** + * Exclude TRON resource tokens (energy and bandwidth), staked tokens, and tokens not from supported networks. + */ + const supportedAssetTypes = assetTypes.filter((assetType) => { + if (SPECIAL_ASSETS.includes(assetType)) { + return false; + } + const { chainId } = parseCaipAssetType(assetType); + return TokenApiClient.supportedNetworks.includes(chainId as Network); + }); + + if (supportedAssetTypes.length !== assetTypes.length) { + this.#logger.warn( + `[TokenApiClient] Received some asset types that are either not supported by the Token API or are excluded resource/staked tokens. They will be ignored. Supported networks: ${TokenApiClient.supportedNetworks.join(', ')}`, + ); + } + + // Split addresses into chunks + const chunks: TokenCaipAssetType[][] = []; + for ( + let index = 0; + index < supportedAssetTypes.length; + index += this.#chunkSize + ) { + chunks.push(supportedAssetTypes.slice(index, index + this.#chunkSize)); + } + + // Fetch metadata for each chunk + const tokenMetadataResponses = ( + await Promise.all( + chunks.map(async (chunk) => this.#fetchTokenMetadataBatch(chunk)), + ) + ).flat(); + + // Flatten and process all metadata + const tokenMetadataMap = new Map< + TokenCaipAssetType, + FungibleAssetMetadata + >(); + + /** + * Iterate over each asset type, and return a default value when metadata is not found, + * to ensure the returned object has exactly the same keys as the input array. + */ + assetTypes.forEach((assetType) => { + const tokenMetadata = tokenMetadataResponses.find( + (item) => item.assetId === assetType, + ); + + if (!tokenMetadata) { + this.#logger.warn( + `No metadata for ${assetType}. Returning default values.`, + ); + tokenMetadataMap.set(assetType, DEFAULT_TOKEN_METADATA); + return; + } + + const name = tokenMetadata.name ?? DEFAULT_TOKEN_METADATA.name; + const symbol = tokenMetadata.symbol ?? DEFAULT_TOKEN_METADATA.symbol; + const decimals = tokenMetadata.decimals ?? DEFAULT_DECIMALS; + + const metadata: FungibleAssetMetadata = { + name, + symbol, + fungible: true, + iconUrl: + tokenMetadata.iconUrl ?? + buildUrl({ + baseUrl: this.#tokenIconBaseUrl, + path: '/api/v2/tokenIcons/assets/{assetType}.png', + pathParams: { + assetType: assetType.replace(/:/gu, '/'), + }, + encodePathParams: false, + }), + units: [ + { + name, + symbol, + decimals, + }, + ], + }; + + tokenMetadataMap.set(assetType, metadata); + }); + + return Object.fromEntries(tokenMetadataMap); + } catch (error) { + this.#logger.error(error, 'Error fetching token metadata'); + throw error; + } + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/token-api/structs.ts b/merged-packages/tron-wallet-snap/src/clients/token-api/structs.ts new file mode 100644 index 00000000..442d7fc8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/token-api/structs.ts @@ -0,0 +1,20 @@ +import { + array, + integer, + object, + optional, + string, +} from '@metamask/superstruct'; + +import { TokenCaipAssetTypeStruct } from '../../services/assets/types'; +import { UrlStruct } from '../../validation/structs'; + +export const TokenMetadataStruct = object({ + decimals: integer(), + assetId: TokenCaipAssetTypeStruct, + name: optional(string()), + symbol: optional(string()), + iconUrl: optional(UrlStruct), +}); + +export const TokenMetadataResponseStruct = array(TokenMetadataStruct); diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts new file mode 100644 index 00000000..66d6d102 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/TronHttpClient.ts @@ -0,0 +1,444 @@ +import { assert } from '@metamask/superstruct'; + +import { + AccountResourcesStruct, + ChainParametersResponseStruct, + ChainParameterStruct, + ContractInfoStruct, + FullNodeTransactionInfoStruct, + GetRewardResponseStruct, + NextMaintenanceTimeStruct, + TRC10TokenInfoStruct, + TriggerConstantContractResponseStruct, +} from './structs'; +import type { AccountResources, GetRewardResponse } from './structs'; +import type { + ChainParameter, + ContractInfo, + FullNodeTransactionInfo, + TRC10TokenInfo, + TRC10TokenMetadata, + TriggerConstantContractRequest, + TriggerConstantContractResponse, +} from './types'; +import type { Network } from '../../constants'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import { hexToString } from '../../utils/hex'; + +/** + * Client for Tron JSON-RPC HTTP endpoints (not the REST API) + * Handles contract interactions, constant contract calls, etc. + */ +export class TronHttpClient { + readonly #clients: Map< + Network, + { + baseUrl: string; + headers: Record; + } + > = new Map(); + + constructor({ configProvider }: { configProvider: ConfigProvider }) { + const { baseUrls } = configProvider.get().tronHttpApi; + + // Initialize clients for all networks + Object.entries(baseUrls).forEach(([network, baseUrl]) => { + const headers: Record = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + }; + + this.#clients.set(network as Network, { baseUrl, headers }); + }); + } + + /** + * Get TRC10 token information by ID + * + * @param tokenId - The TRC10 token ID + * @param network - The network to query + * @returns Promise - Token information + */ + async getTRC10TokenById( + tokenId: string, + network: Network, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/wallet/getassetissuebyid', + }); + + const body = JSON.stringify({ + value: tokenId, + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const tokenData: TRC10TokenInfo = await response.json(); + + // Validate response schema + assert(tokenData, TRC10TokenInfoStruct); + + return tokenData; + } + + /** + * Get TRC10 token metadata (name, symbol, decimals) + * + * @param tokenId - The TRC10 token ID + * @param network - The network to query + * @returns Promise - Token metadata + */ + async getTRC10TokenMetadata( + tokenId: string, + network: Network, + ): Promise { + const tokenInfo = await this.getTRC10TokenById(tokenId, network); + + return { + name: hexToString(tokenInfo.name), + symbol: hexToString(tokenInfo.abbr), + decimals: tokenInfo.precision, + }; + } + + /** + * Get account resources (Energy and Bandwidth). + * For inactive accounts, returns an empty object `{}` with all resource values effectively being 0. + * + * @see https://developers.tron.network/reference/getaccountresource + * @param network - Network to query. + * @param accountAddress - Account address in base58 format. + * @returns Promise - Account resources (energy, bandwidth, etc.). + * @throws Error - HTTP errors or configuration errors. + */ + async getAccountResources( + network: Network, + accountAddress: string, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/wallet/getaccountresource', + }); + + const body = JSON.stringify({ + address: accountAddress, + visible: true, + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const accountResources: AccountResources = await response.json(); + + // Validate response schema + assert(accountResources, AccountResourcesStruct); + + return accountResources; + } + + /** + * Get transaction info by transaction ID from Full Node API + * + * @see https://developers.tron.network/reference/gettransactioninfobyid + * @param network - Network to query + * @param txId - Transaction ID + * @returns Promise - Transaction info with block details, or null if not found/confirmed + */ + async getTransactionInfoById( + network: Network, + txId: string, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/wallet/gettransactioninfobyid', + }); + + const body = JSON.stringify({ + value: txId, + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + if (response.status === 404) { + return null; // Transaction not found + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const txInfo: FullNodeTransactionInfo = await response.json(); + + // If the response is empty or doesn't have the expected data, return null + // Note: GetTransactionInfoById only returns data for confirmed transactions + if (!txInfo?.id || !txInfo?.blockNumber) { + return null; + } + + // Validate response schema + assert(txInfo, FullNodeTransactionInfoStruct); + + return txInfo; + } + + /** + * Get the timestamp of the next maintenance period. + * Chain parameters can only change at maintenance periods (every ~6 hours). + * + * @see https://developers.tron.network/reference/getnextmaintenancetime + * @param network - The network to query + * @returns Promise - Unix timestamp in milliseconds of next maintenance + */ + async getNextMaintenanceTime(network: Network): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/wallet/getnextmaintenancetime', + }); + + const response = await fetch(url, { + method: 'POST', + headers, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + // Validate response schema + assert(data, NextMaintenanceTimeStruct); + + return data.num; + } + + /** + * Get chain parameters for a specific network. + * + * @see https://developers.tron.network/reference/wallet-getchainparameters + * @param network - The network to query (e.g., 'mainnet', 'shasta') + * @returns Promise - Chain parameters data + */ + async getChainParameters(network: Network): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/wallet/getchainparameters', + }); + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData = await response.json(); + + // Validate response schema + assert(rawData, ChainParametersResponseStruct); + + if (!rawData.chainParameter) { + throw new Error('No chain parameters found'); + } + + // Validate each chain parameter + for (const param of rawData.chainParameter) { + assert(param, ChainParameterStruct); + } + + return rawData.chainParameter; + } + + /** + * Trigger a constant contract call to estimate energy consumption. + * This is a read-only call that doesn't broadcast to the network. + * + * @see https://developers.tron.network/reference/triggerconstantcontract + * @param network - The network to query (e.g., 'mainnet', 'shasta') + * @param request - The contract call parameters + * @returns Promise - Energy estimation and execution result + */ + async triggerConstantContract( + network: Network, + request: TriggerConstantContractRequest, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/wallet/triggerconstantcontract', + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify( + Object.fromEntries( + Object.entries(request).filter( + ([_key, value]) => value !== undefined && value !== null, + ), + ), + ), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result: TriggerConstantContractResponse = await response.json(); + + // Validate response schema + assert(result, TriggerConstantContractResponseStruct); + + return result; + } + + /** + * Get smart contract info including energy sharing parameters. + * + * @see https://developers.tron.network/reference/wallet-getcontract + * @param network - Network to query + * @param contractAddress - Contract address in base58 format + * @returns Promise - Contract info or null if not found + */ + async getContract( + network: Network, + contractAddress: string, + ): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/wallet/getcontract', + }); + + const body = JSON.stringify({ + value: contractAddress, + visible: false, + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const contractInfo: ContractInfo = await response.json(); + + // Empty response means contract not found (TRON returns {} for non-existent contracts) + if (!contractInfo || Object.keys(contractInfo).length === 0) { + return null; + } + + // Validate response schema + assert(contractInfo, ContractInfoStruct); + + return contractInfo; + } + + /** + * Get unclaimed staking rewards for an address. + * Returns the amount of TRX rewards from voting that can be claimed. + * + * @see https://developers.tron.network/reference/getreward + * @param network - Network to query + * @param accountAddress - Account address in base58 format + * @returns Promise - Unclaimed rewards in sun (0 if no rewards) + */ + async getReward(network: Network, accountAddress: string): Promise { + const client = this.#clients.get(network); + if (!client) { + throw new Error(`No client configured for network: ${network}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/wallet/getReward', + }); + + const body = JSON.stringify({ + address: accountAddress, + visible: true, + }); + + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rewardResponse: GetRewardResponse = await response.json(); + + // Validate response schema + assert(rewardResponse, GetRewardResponseStruct); + + return rewardResponse.reward ?? 0; + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/index.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/index.ts new file mode 100644 index 00000000..9edc7800 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/index.ts @@ -0,0 +1,3 @@ +export { TronHttpClient } from './TronHttpClient'; +export type { AccountResources } from './structs'; +export type * from './types'; diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/structs.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/structs.ts new file mode 100644 index 00000000..aa13436e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/structs.ts @@ -0,0 +1,240 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + min, + number, + optional, + record, + string, + type, +} from '@metamask/superstruct'; + +/** + * Superstruct definitions for TronHttpClient response validation. + * These structs ensure that API responses conform to expected schemas + * and provide bounds validation to prevent malicious data injection. + */ + +// -------------------------------------------------------------------------- +// TRC10 Token Info Structs +// -------------------------------------------------------------------------- + +export const TRC10TokenInfoStruct = type({ + id: string(), + owner_address: string(), + name: string(), + abbr: string(), + total_supply: min(number(), 0), + trx_num: min(number(), 0), + // eslint-disable-next-line id-denylist + num: min(number(), 0), + precision: min(number(), 0), + start_time: min(number(), 0), + end_time: min(number(), 0), + description: string(), + url: string(), +}); + +export type ValidatedTRC10TokenInfo = Infer; + +// -------------------------------------------------------------------------- +// Account Resources Structs +// -------------------------------------------------------------------------- + +export const AccountResourcesStruct = type({ + freeNetUsed: optional(min(number(), 0)), + freeNetLimit: optional(min(number(), 0)), + NetUsed: optional(min(number(), 0)), + NetLimit: optional(min(number(), 0)), + TotalNetLimit: optional(min(number(), 0)), + TotalNetWeight: optional(min(number(), 0)), + tronPowerUsed: optional(min(number(), 0)), + tronPowerLimit: optional(min(number(), 0)), + EnergyUsed: optional(min(number(), 0)), + EnergyLimit: optional(min(number(), 0)), + TotalEnergyLimit: optional(min(number(), 0)), + TotalEnergyWeight: optional(min(number(), 0)), +}); + +/** + * Account resources derived from the Superstruct schema. + * + * @see https://developers.tron.network/reference/getaccountresource + */ +export type AccountResources = Infer; + +// -------------------------------------------------------------------------- +// FullNodeTransactionInfo Structs +// -------------------------------------------------------------------------- + +export const FullNodeTransactionReceiptStruct = type({ + energy_usage: optional(min(number(), 0)), + energy_fee: optional(min(number(), 0)), + origin_energy_usage: optional(min(number(), 0)), + energy_usage_total: optional(min(number(), 0)), + net_usage: optional(min(number(), 0)), + net_fee: optional(min(number(), 0)), + result: optional(string()), + energy_penalty_total: optional(min(number(), 0)), +}); + +export const FullNodeTransactionLogStruct = type({ + address: string(), + topics: array(string()), + data: string(), +}); + +export const FullNodeInternalTransactionStruct = type({ + hash: optional(string()), + caller_address: optional(string()), + transferTo_address: optional(string()), + callValueInfo: optional( + array( + type({ + callValue: optional(min(number(), 0)), + tokenId: optional(string()), + }), + ), + ), + note: optional(string()), + rejected: optional(boolean()), +}); + +export const FullNodeTransactionInfoStruct = type({ + id: optional(string()), + fee: optional(min(number(), 0)), + blockNumber: optional(min(number(), 0)), + blockTimeStamp: optional(min(number(), 0)), + contractResult: optional(array(string())), + contract_address: optional(string()), + receipt: optional(FullNodeTransactionReceiptStruct), + log: optional(array(FullNodeTransactionLogStruct)), + result: optional(string()), + resMessage: optional(string()), + withdraw_amount: optional(min(number(), 0)), + unfreeze_amount: optional(min(number(), 0)), + internal_transactions: optional(array(FullNodeInternalTransactionStruct)), + withdraw_expire_amount: optional(min(number(), 0)), + cancel_unfreezeV2_amount: optional(record(string(), number())), +}); + +export type ValidatedFullNodeTransactionInfo = Infer< + typeof FullNodeTransactionInfoStruct +>; + +// -------------------------------------------------------------------------- +// Next Maintenance Time Structs +// -------------------------------------------------------------------------- + +/** + * Response from /wallet/getnextmaintenancetime endpoint. + * The `num` field contains the Unix timestamp (in milliseconds) of the next maintenance period. + * + * @see https://developers.tron.network/reference/getnextmaintenancetime + */ +export const NextMaintenanceTimeStruct = type({ + // eslint-disable-next-line id-denylist + num: min(number(), 0), +}); + +export type ValidatedNextMaintenanceTime = Infer< + typeof NextMaintenanceTimeStruct +>; + +// -------------------------------------------------------------------------- +// Chain Parameter Structs +// -------------------------------------------------------------------------- + +export const ChainParameterStruct = type({ + key: string(), + value: optional(number()), +}); + +export type ValidatedChainParameter = Infer; + +export const ChainParametersResponseStruct = type({ + chainParameter: array(ChainParameterStruct), +}); + +// -------------------------------------------------------------------------- +// TriggerConstantContract Structs +// -------------------------------------------------------------------------- + +export const TriggerConstantContractResultStruct = type({ + result: boolean(), + message: optional(string()), +}); + +export const TriggerConstantContractTransactionRawDataStruct = type({ + contract: array( + type({ + parameter: type({ + value: type({ + data: string(), + owner_address: string(), + contract_address: string(), + }), + type_url: string(), + }), + type: string(), + }), + ), + ref_block_bytes: string(), + ref_block_hash: string(), + expiration: min(number(), 0), + timestamp: min(number(), 0), +}); + +export const TriggerConstantContractTransactionStruct = type({ + ret: array( + type({ + ret: optional(string()), + }), + ), + visible: boolean(), + txID: string(), + raw_data: TriggerConstantContractTransactionRawDataStruct, + raw_data_hex: string(), +}); + +export const TriggerConstantContractResponseStruct = type({ + result: TriggerConstantContractResultStruct, + energy_used: min(number(), 0), + constant_result: array(string()), + energy_penalty: optional(min(number(), 0)), + transaction: TriggerConstantContractTransactionStruct, +}); + +export type ValidatedTriggerConstantContractResponse = Infer< + typeof TriggerConstantContractResponseStruct +>; + +// -------------------------------------------------------------------------- +// Contract Info Structs (Energy Sharing Parameters) +// -------------------------------------------------------------------------- + +export const ContractInfoStruct = type({ + origin_address: optional(string()), + consume_user_resource_percent: optional(min(number(), 0)), + origin_energy_limit: optional(min(number(), 0)), +}); + +export type ValidatedContractInfo = Infer; + +// -------------------------------------------------------------------------- +// Staking Rewards Structs +// -------------------------------------------------------------------------- + +/** + * Response from the /wallet/getReward endpoint. + * Returns the unclaimed staking rewards for an address. + * + * @see https://developers.tron.network/reference/getreward + */ +export const GetRewardResponseStruct = type({ + reward: optional(min(number(), 0)), +}); + +export type GetRewardResponse = Infer; diff --git a/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts b/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts new file mode 100644 index 00000000..1743fdef --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tron-http/types.ts @@ -0,0 +1,168 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * Full Node API response for GetTransactionInfoById + * Returns detailed transaction info including block number and fees + */ +export type FullNodeTransactionInfo = { + id?: string; + fee?: number; + blockNumber?: number; + blockTimeStamp?: number; + contractResult?: string[]; + contract_address?: string; + receipt?: { + energy_usage?: number; + energy_fee?: number; + origin_energy_usage?: number; + energy_usage_total?: number; + net_usage?: number; + net_fee?: number; + result?: string; + energy_penalty_total?: number; + }; + log?: { + address: string; + topics: string[]; + data: string; + }[]; + result?: string; + resMessage?: string; + withdraw_amount?: number; + unfreeze_amount?: number; + internal_transactions?: { + hash: string; + caller_address: string; + transferTo_address: string; + callValueInfo: { + callValue?: number; + tokenId?: string; + }[]; + note: string; + rejected: boolean; + }[]; + withdraw_expire_amount?: number; + cancel_unfreezeV2_amount?: Record; +}; + +export type TRC10TokenInfo = { + id: string; + owner_address: string; + name: string; + abbr: string; + total_supply: number; + trx_num: number; + // eslint-disable-next-line id-denylist + num: number; + precision: number; + start_time: number; + end_time: number; + description: string; + url: string; +}; + +export type TRC10TokenMetadata = { + name: string; + symbol: string; + decimals: number; +}; + +/** + * Next maintenance time response + * + * @see https://developers.tron.network/reference/getnextmaintenancetime + */ +export type NextMaintenanceTime = { + /** + * Unix timestamp in milliseconds of the next maintenance period + */ + // eslint-disable-next-line id-denylist + num: number; +}; + +/** + * Chain parameter + * + * @see https://developers.tron.network/reference/wallet-getchainparameters + */ +export type ChainParameter = { + key: string; + value?: number; +}; + +/** + * Request parameters for TriggerConstantContract + * + * @see https://developers.tron.network/reference/triggerconstantcontract + */ +export type TriggerConstantContractRequest = { + owner_address: string; + contract_address: string; + data: string; + call_value?: number; + token_id?: number; + visible?: boolean; // Default to true + call_token_id?: number; + call_token_value?: number; +}; + +/** + * Response from TriggerConstantContract + * + * @see https://developers.tron.network/reference/triggerconstantcontract + */ +export type TriggerConstantContractResponse = { + result: { + result: boolean; + message?: string; + }; + energy_used: number; + constant_result: string[]; + energy_penalty?: number; + transaction: { + ret: { + ret: string; + }[]; + visible: boolean; + txID: string; + raw_data: { + contract: { + parameter: { + value: { + data: string; + owner_address: string; + contract_address: string; + }; + type_url: string; + }; + type: string; + }[]; + ref_block_bytes: string; + ref_block_hash: string; + expiration: number; + timestamp: number; + }; + raw_data_hex: string; + }; +}; + +/** + * Contract energy sharing parameters from /wallet/getcontract endpoint. + * We only extract the fields needed for fee calculation. + * + * @see https://developers.tron.network/reference/wallet-getcontract + */ +export type ContractInfo = { + /** + * The address of the contract deployer. + */ + origin_address?: string; + /** + * Percentage of energy the USER/CALLER pays (0-100). + */ + consume_user_resource_percent?: number; + /** + * Max energy the deployer will subsidize per transaction. + */ + origin_energy_limit?: number; +}; diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.test.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.test.ts new file mode 100644 index 00000000..885c8d9f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.test.ts @@ -0,0 +1,432 @@ +import { TrongridApiClient } from './TrongridApiClient'; +import type { Trc20Balance, TransactionInfo } from './types'; +import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; +import { Network } from '../../constants'; +import { ConfigProvider } from '../../services/config'; +import nativeTransferWithoutTimestampMock from '../../services/transactions/mocks/trongrid/account-transactions/native-transfer-without-timestamp.json'; +import nativeTransferMock from '../../services/transactions/mocks/trongrid/account-transactions/native-transfer.json'; +import { mockLogger } from '../../utils/mockLogger'; +import type { Serializable } from '../../utils/serialization/types'; +import { TronHttpClient } from '../tron-http/TronHttpClient'; + +type WithTrongridApiClientCallback = (payload: { + client: TrongridApiClient; + configProvider: ConfigProvider; + tronHttpClient: TronHttpClient; + cache: ICache; +}) => Promise | ReturnValue; + +type WithTrongridApiClientOptions = { + options: { + trongridBaseUrls?: Record; + tronHttpBaseUrls?: Record; + }; +}; + +/** + * Wraps tests for TrongridApiClient by creating a fresh client with all + * dependencies and restoring `global.fetch` afterward. + * + * @param args - Either a callback, or an options bag + callback. Options allow + * overriding base URLs. The callback receives the client and its dependencies. + * @returns The return value of the callback. + */ +async function withTrongridApiClient( + ...args: + | [WithTrongridApiClientCallback] + | [WithTrongridApiClientOptions, WithTrongridApiClientCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const defaultBaseUrls = { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: 'https://nile.trongrid.io', + [Network.Shasta]: 'https://api.shasta.trongrid.io', + }; + + const configProvider = new ConfigProvider(); + const baseConfig = configProvider.get(); + jest.spyOn(configProvider, 'get').mockReturnValue({ + ...baseConfig, + trongridApi: { + baseUrls: options.trongridBaseUrls ?? defaultBaseUrls, + }, + tronHttpApi: { + baseUrls: options.tronHttpBaseUrls ?? defaultBaseUrls, + }, + }); + + const tronHttpClient = new TronHttpClient({ configProvider }); + const cache = new InMemoryCache(mockLogger); + const client = new TrongridApiClient({ + configProvider, + tronHttpClient, + cache, + }); + + // eslint-disable-next-line no-restricted-globals + const originalFetch = global.fetch; + try { + return await testFunction({ + client, + configProvider, + tronHttpClient, + cache, + }); + } finally { + // eslint-disable-next-line no-restricted-globals + global.fetch = originalFetch; + } +} + +describe('TrongridApiClient', () => { + describe('getTrc20BalancesByAddress', () => { + const mockAddress = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const normalizeBalances = (balances: Trc20Balance[]): Trc20Balance[] => + balances.map((balance) => ({ ...balance })); + + it('fetches and returns TRC20 balances for an address', async () => { + await withTrongridApiClient(async ({ client }) => { + const mockTrc20Balances: Trc20Balance[] = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, + { TGPuQ7g7H8GsUEXhwvvJop4zCncurEh2ht: '88123456' }, + ]; + + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: mockTrc20Balances, + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 4 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); + // eslint-disable-next-line no-restricted-globals + expect(global.fetch).toHaveBeenCalledWith( + `https://api.trongrid.io/v1/accounts/${mockAddress}/trc20/balance`, + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }), + ); + }); + }); + + it('returns empty array when no TRC20 tokens are found', async () => { + await withTrongridApiClient(async ({ client }) => { + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: [], + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toStrictEqual([]); + }); + }); + + it('returns empty array when data is undefined', async () => { + await withTrongridApiClient(async ({ client }) => { + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toStrictEqual([]); + }); + }); + + it('throws error when network base URL is invalid', async () => { + const invalidBaseUrls = { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: '', + [Network.Shasta]: '', + }; + + await withTrongridApiClient( + { + options: { + trongridBaseUrls: invalidBaseUrls, + tronHttpBaseUrls: invalidBaseUrls, + }, + }, + async ({ client }) => { + await expect( + client.getTrc20BalancesByAddress(Network.Nile, mockAddress), + ).rejects.toThrow('Invalid URL format'); + }, + ); + }); + + it('throws error when HTTP request fails', async () => { + await withTrongridApiClient(async ({ client }) => { + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response('', { status: 500 }), + ); + + await expect( + client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), + ).rejects.toThrow('HTTP error! status: 500'); + }); + }); + + it('throws error when API returns success: false', async () => { + await withTrongridApiClient(async ({ client }) => { + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: [], + success: false, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), + ).rejects.toThrow('API request failed'); + }); + }); + + it('works with different networks', async () => { + await withTrongridApiClient(async ({ client }) => { + const mockTrc20Balances: Trc20Balance[] = [ + { TTestToken123: '1000000' }, + ]; + + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: mockTrc20Balances, + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Nile, + mockAddress, + ); + + expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); + // eslint-disable-next-line no-restricted-globals + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('nile.trongrid.io'), + expect.any(Object), + ); + }); + }); + + it('validates TRC20 balance data structure', async () => { + await withTrongridApiClient(async ({ client }) => { + const validBalances: Trc20Balance[] = [ + { TokenAddress1: '100' }, + { TokenAddress2: '200' }, + ]; + + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: validBalances, + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 2 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toHaveLength(2); + const normalizedBalances = normalizeBalances(result); + expect(normalizedBalances[0]).toStrictEqual({ TokenAddress1: '100' }); + expect(normalizedBalances[1]).toStrictEqual({ TokenAddress2: '200' }); + }); + }); + }); + + describe('getTransactionInfoByAddress', () => { + const mockAddress = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const mockTx = nativeTransferMock as TransactionInfo; + + it('fetches transactions without a limit query parameter by default', async () => { + await withTrongridApiClient(async ({ client }) => { + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: [mockTx], + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTransactionInfoByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toHaveLength(1); + // eslint-disable-next-line no-restricted-globals + expect(global.fetch).toHaveBeenCalledWith( + `https://api.trongrid.io/v1/accounts/${mockAddress}/transactions`, + expect.any(Object), + ); + }); + }); + + it('appends limit to the query string when options.limit is set', async () => { + await withTrongridApiClient(async ({ client }) => { + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: [mockTx], + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await client.getTransactionInfoByAddress(Network.Mainnet, mockAddress, { + limit: 1, + }); + + // eslint-disable-next-line no-restricted-globals + expect(global.fetch).toHaveBeenCalledWith( + `https://api.trongrid.io/v1/accounts/${mockAddress}/transactions?limit=1`, + expect.any(Object), + ); + }); + }); + + it('accepts legacy internal_transactions entries with optional fields omitted', async () => { + await withTrongridApiClient(async ({ client }) => { + // Example seen live on tx aaff541203021b7398bfd29e0eeb28417eab93e113f2bf504d2169d1b8161500, + // where a legacy internal_transactions entry omitted data.call_value. + const transactionWithSparseInternalTransaction = { + ...mockTx, + // eslint-disable-next-line @typescript-eslint/naming-convention + internal_transactions: [ + { + data: { + note: '63616c6c', + }, + }, + ], + }; + + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: [transactionWithSparseInternalTransaction], + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTransactionInfoByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toHaveLength(1); + }); + }); + + it('accepts raw transactions when raw_data.timestamp is omitted', async () => { + await withTrongridApiClient(async ({ client }) => { + // Example seen live on tx c4e8c4a45830e882e92062ede0ecd09702c51e4732385b9cf33590d470b08357, + // where Trongrid omitted raw_data.timestamp entirely. + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: [nativeTransferWithoutTimestampMock], + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTransactionInfoByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toHaveLength(1); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts new file mode 100644 index 00000000..86340d31 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.ts @@ -0,0 +1,335 @@ +import { assert } from '@metamask/superstruct'; + +import { + ContractTransactionInfoStruct, + Trc20BalanceStruct, + TransactionInfoStruct, + TronAccountStruct, + TrongridApiMetaStruct, +} from './structs'; +import type { + ContractTransactionInfo, + Trc20Balance, + TransactionInfo, + TronAccount, + TrongridApiResponse, +} from './types'; +import type { ICache } from '../../caching/ICache'; +import { + useCacheUntil, + type ResultWithExpiry, +} from '../../caching/useCacheUntil'; +import type { Network } from '../../constants'; +import type { ConfigProvider } from '../../services/config'; +import { buildUrl } from '../../utils/buildUrl'; +import type { Serializable } from '../../utils/serialization/types'; +import type { TronHttpClient } from '../tron-http/TronHttpClient'; +import type { ChainParameter } from '../tron-http/types'; + +export class TrongridApiClient { + readonly #clients: Map< + Network, + { + baseUrl: string; + headers: Record; + } + > = new Map(); + + readonly #tronHttpClient: TronHttpClient; + + readonly #cache: ICache; + + /** + * Cached version of getChainParameters that uses maintenance-aligned expiry. + * The cache is invalidated when the next maintenance period is reached. + */ + readonly #cachedGetChainParameters: ( + scope: Network, + ) => Promise; + + constructor({ + configProvider, + tronHttpClient, + cache, + }: { + configProvider: ConfigProvider; + tronHttpClient: TronHttpClient; + cache: ICache; + }) { + const { baseUrls } = configProvider.get().trongridApi; + + // Initialize clients for all networks + Object.entries(baseUrls).forEach(([network, baseUrl]) => { + const headers: Record = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + }; + + this.#clients.set(network as Network, { baseUrl, headers }); + }); + + this.#tronHttpClient = tronHttpClient; + this.#cache = cache; + + // Create cached version of getChainParameters with maintenance-aligned expiry + this.#cachedGetChainParameters = useCacheUntil( + this.#fetchChainParametersWithExpiry.bind(this), + this.#cache, + { functionName: 'TrongridApiClient:getChainParameters' }, + ); + } + + /** + * Get account information by address for a specific network. + * The returned data includes TRX balance, TRC10 assets and TRC20 token balances. + * + * @see https://developers.tron.network/reference/get-account-info-by-address + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @param address - The TRON address to query + * @returns Promise - Account data including balances. + * @throws Error - "Account not found or no data returned" for inactive accounts. + * @throws Error - HTTP errors or API failures. + */ + async getAccountInfoByAddress( + scope: Network, + address: string, + ): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/v1/accounts/{address}', + pathParams: { address }, + }); + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData: TrongridApiResponse = await response.json(); + + // Validate API response structure + if (typeof rawData.success !== 'boolean' || !rawData.success) { + throw new Error('API request failed'); + } + assert(rawData.meta, TrongridApiMetaStruct); + + if (!rawData.data || rawData.data.length === 0) { + throw new Error('Account not found or no data returned'); + } + + const account = rawData.data[0]; + + if (!account) { + throw new Error('No data'); + } + + // Validate account data schema + assert(account, TronAccountStruct); + + return account; + } + + /** + * Get native TRX + TRC10 transaction history for an account address. + * + * @see https://developers.tron.network/reference/get-transaction-info-by-account-address + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @param address - The TRON address to query + * @param options - Optional query options for TronGrid. + * @param options.limit - When set, passed as the TronGrid `limit` query parameter. + * @returns Promise - Transaction data in camelCase + */ + async getTransactionInfoByAddress( + scope: Network, + address: string, + options?: { limit?: number }, + ): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const queryParams: Record | undefined = + options?.limit === undefined + ? undefined + : { limit: String(options.limit) }; + const url = buildUrl({ + baseUrl, + path: '/v1/accounts/{address}/transactions', + pathParams: { address }, + ...(queryParams ? { queryParams } : {}), + }); + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData: TrongridApiResponse = + await response.json(); + + // Validate API response structure + if (typeof rawData.success !== 'boolean' || !rawData.success) { + throw new Error('API request failed'); + } + assert(rawData.meta, TrongridApiMetaStruct); + + if (!rawData.data) { + throw new Error('API request failed'); + } + + // Validate each transaction info + for (const txInfo of rawData.data) { + assert(txInfo, TransactionInfoStruct); + } + + return rawData.data; + } + + /** + * Get TRC20 transaction history for an account address. + * + * @see https://developers.tron.network/reference/get-trc20-transaction-info-by-account-address + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @param address - The TRON address to query + * @returns Promise - Contract transaction data in camelCase + */ + async getContractTransactionInfoByAddress( + scope: Network, + address: string, + ): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/v1/accounts/{address}/transactions/trc20', + pathParams: { address }, + }); + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData: TrongridApiResponse = + await response.json(); + + // Validate API response structure + if (typeof rawData.success !== 'boolean' || !rawData.success) { + throw new Error('API request failed'); + } + assert(rawData.meta, TrongridApiMetaStruct); + + if (!rawData.data) { + throw new Error('API request failed'); + } + + // Validate each contract transaction info + for (const txInfo of rawData.data) { + assert(txInfo, ContractTransactionInfoStruct); + } + + return rawData.data; + } + + /** + * Get TRC20 token balances for an account address. + * This endpoint works for inactive accounts that haven't been activated yet. + * + * @see https://developers.tron.network/reference/get-trc20-token-holder-balances + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @param address - The TRON address to query + * @returns Promise - Array of TRC20 balances (contract address -> balance) + */ + async getTrc20BalancesByAddress( + scope: Network, + address: string, + ): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/v1/accounts/{address}/trc20/balance', + pathParams: { address }, + }); + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData: TrongridApiResponse = await response.json(); + + // Validate API response structure + if (typeof rawData.success !== 'boolean' || !rawData.success) { + throw new Error('API request failed'); + } + assert(rawData.meta, TrongridApiMetaStruct); + + if (!rawData.data) { + return []; + } + + // Validate each TRC20 balance entry + for (const balance of rawData.data) { + assert(balance, Trc20BalanceStruct); + } + + return rawData.data; + } + + /** + * Get chain parameters for a specific network. + * Results are cached until the next maintenance period (every ~6 hours). + * + * @see https://api.trongrid.io/wallet/getchainparameters + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @returns Promise - Chain parameters data + */ + async getChainParameters(scope: Network): Promise { + return this.#cachedGetChainParameters(scope); + } + + /** + * Internal method to fetch chain parameters with expiry timestamp. + * Fetches both chain parameters and next maintenance time in parallel. + * + * @param scope - The network to query + * @returns Promise with result and expiresAt for caching + */ + async #fetchChainParametersWithExpiry( + scope: Network, + ): Promise> { + // Fetch both in parallel for efficiency + // Delegates to TronHttpClient for the actual API calls + const [parameters, nextMaintenanceTime] = await Promise.all([ + this.#tronHttpClient.getChainParameters(scope), + this.#tronHttpClient.getNextMaintenanceTime(scope), + ]); + + return { + result: parameters, + expiresAt: nextMaintenanceTime, // Exact maintenance time, no buffer needed + }; + } +} diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/structs.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/structs.ts new file mode 100644 index 00000000..78c0875f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/structs.ts @@ -0,0 +1,244 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + min, + number, + optional, + record, + string, + type, +} from '@metamask/superstruct'; + +import { TronAddressStruct } from '../../validation/structs'; + +/** + * Superstruct definitions for TrongridApiClient response validation. + * These structs ensure that API responses conform to expected schemas + * and provide bounds validation to prevent malicious data injection. + */ + +// -------------------------------------------------------------------------- +// TronAccount Response Structs +// -------------------------------------------------------------------------- + +export const RawTronKeyStruct = type({ + address: string(), + weight: number(), +}); + +export const RawTronPermissionStruct = type({ + keys: array(RawTronKeyStruct), + threshold: number(), + permission_name: string(), + operations: optional(string()), + id: optional(number()), + type: optional(string()), +}); + +export const RawTronAccountResourceStruct = type({ + energy_window_optimized: optional(boolean()), + energy_window_size: optional(number()), + delegated_frozenV2_balance_for_energy: optional(min(number(), 0)), + delegated_frozenV2_balance_for_bandwidth: optional(min(number(), 0)), +}); + +export const RawTronFrozenV2Struct = type({ + amount: optional(min(number(), 0)), + type: optional(string()), +}); + +export const RawTronUnfrozenV2Struct = type({ + unfreeze_amount: min(number(), 0), + unfreeze_expire_time: min(number(), 0), + type: optional(string()), +}); + +export const RawTronVoteStruct = type({ + vote_address: string(), + vote_count: min(number(), 0), +}); + +// AssetV2 entry with key-value structure +export const RawTronAssetV2Struct = type({ + key: string(), + value: number(), +}); + +export const TronAccountStruct = type({ + owner_permission: optional(RawTronPermissionStruct), + account_resource: optional(RawTronAccountResourceStruct), + active_permission: optional(array(RawTronPermissionStruct)), + address: TronAddressStruct, + create_time: optional(min(number(), 0)), + latest_opration_time: optional(min(number(), 0)), + frozenV2: optional(array(RawTronFrozenV2Struct)), + unfrozenV2: optional(array(RawTronUnfrozenV2Struct)), + balance: optional(min(number(), 0)), + assetV2: optional(array(RawTronAssetV2Struct)), + trc20: optional(array(record(string(), string()))), + latest_consume_free_time: optional(min(number(), 0)), + votes: optional(array(RawTronVoteStruct)), + latest_withdraw_time: optional(min(number(), 0)), + net_window_size: optional(min(number(), 0)), + net_window_optimized: optional(boolean()), +}); + +export type ValidatedTronAccount = Infer; + +// -------------------------------------------------------------------------- +// Transaction Info Structs +// -------------------------------------------------------------------------- + +export const TransactionResultStruct = type({ + contractRet: optional(string()), + fee: optional(min(number(), 0)), +}); + +export const ContractVoteStruct = type({ + vote_address: string(), + vote_count: min(number(), 0), +}); + +export const ContractValueStruct = type({ + owner_address: optional(string()), + to_address: optional(string()), + unfreeze_balance: optional(min(number(), 0)), + votes: optional(array(ContractVoteStruct)), + frozen_balance: optional(min(number(), 0)), + data: optional(string()), + contract_address: optional(string()), + call_value: optional(min(number(), 0)), + amount: optional(min(number(), 0)), + asset_name: optional(string()), +}); + +export const ContractParameterStruct = type({ + value: ContractValueStruct, + type_url: string(), +}); + +export const ContractInfoStruct = type({ + parameter: ContractParameterStruct, + type: string(), +}); + +export const InternalTransactionCallValueStruct = type({ + // eslint-disable-next-line id-length + _: optional(number()), + callValue: optional(min(number(), 0)), + tokenId: optional(string()), +}); + +export const InternalTransactionDataStruct = type({ + note: optional(string()), + rejected: optional(boolean()), + call_value: optional(InternalTransactionCallValueStruct), +}); + +export const InternalTransactionStruct = type({ + internal_tx_id: optional(string()), + data: optional(InternalTransactionDataStruct), + to_address: optional(string()), + from_address: optional(string()), +}); + +export const RawTransactionDataStruct = type({ + contract: array(ContractInfoStruct), + ref_block_bytes: string(), + ref_block_hash: string(), + expiration: min(number(), 0), + timestamp: optional(min(number(), 0)), + fee_limit: optional(min(number(), 0)), +}); + +export const TransactionInfoStruct = type({ + ret: optional(array(TransactionResultStruct)), + signature: optional(array(string())), + txID: string(), + net_usage: optional(min(number(), 0)), + raw_data_hex: optional(string()), + net_fee: optional(min(number(), 0)), + energy_usage: optional(min(number(), 0)), + blockNumber: optional(min(number(), 0)), + block_timestamp: optional(min(number(), 0)), + energy_fee: optional(min(number(), 0)), + energy_usage_total: optional(min(number(), 0)), + raw_data: RawTransactionDataStruct, + internal_transactions: optional(array(InternalTransactionStruct)), +}); + +export type ValidatedTransactionInfo = Infer; + +// -------------------------------------------------------------------------- +// Contract Transaction Info Structs (TRC20) +// -------------------------------------------------------------------------- + +export const TokenInfoStruct = type({ + symbol: string(), + address: string(), + decimals: min(number(), 0), + name: string(), +}); + +export const ContractTransactionInfoStruct = type({ + transaction_id: string(), + token_info: TokenInfoStruct, + block_timestamp: min(number(), 0), + from: string(), + to: string(), + type: string(), + value: string(), +}); + +export type ValidatedContractTransactionInfo = Infer< + typeof ContractTransactionInfoStruct +>; + +// -------------------------------------------------------------------------- +// Generic API Response Wrapper Struct +// -------------------------------------------------------------------------- + +export const TrongridApiMetaStruct = type({ + at: min(number(), 0), + page_size: min(number(), 0), +}); + +// Pre-built response structs for common use cases +export const TrongridAccountResponseStruct = type({ + data: array(TronAccountStruct), + success: boolean(), + meta: TrongridApiMetaStruct, +}); + +export const TrongridTransactionInfoResponseStruct = type({ + data: array(TransactionInfoStruct), + success: boolean(), + meta: TrongridApiMetaStruct, +}); + +export const TrongridContractTransactionInfoResponseStruct = type({ + data: array(ContractTransactionInfoStruct), + success: boolean(), + meta: TrongridApiMetaStruct, +}); + +// -------------------------------------------------------------------------- +// TRC20 Balance Response Structs (for inactive account fallback) +// -------------------------------------------------------------------------- + +/** + * Struct for validating individual TRC20 balance entries. + * Each entry is a record mapping contract address to balance string. + */ +export const Trc20BalanceStruct = record(string(), string()); + +/** + * Struct for validating the TRC20 balance API response. + */ +export const TrongridTrc20BalanceResponseStruct = type({ + data: array(Trc20BalanceStruct), + success: boolean(), + meta: TrongridApiMetaStruct, +}); diff --git a/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts new file mode 100644 index 00000000..e8a51309 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/trongrid/types.ts @@ -0,0 +1,204 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export type TrongridApiResponse = { + data: T; + success: boolean; + meta: { + at: number; + page_size: number; + }; +}; + +export type TronAccount = { + owner_permission: RawTronPermission; + account_resource: RawTronAccountResource; + active_permission: RawTronPermission[]; + address: string; + create_time: number; + latest_opration_time: number; + frozenV2: RawTronFrozenV2[]; + unfrozenV2: RawTronUnfrozenV2[]; + balance: number; + assetV2?: Record[]; + trc20?: Record[]; + latest_consume_free_time: number; + votes: RawTronVote[]; + latest_withdraw_time: number; + net_window_size: number; + net_window_optimized: boolean; +}; + +export type RawTronPermission = { + keys: RawTronKey[]; + threshold: number; + permission_name: string; + operations?: string; + id?: number; + type?: string; +}; + +export type RawTronKey = { + address: string; + weight: number; +}; + +export type RawTronAccountResource = { + energy_window_optimized: boolean; + energy_window_size: number; + + delegated_frozenV2_balance_for_energy?: number; + delegated_frozenV2_balance_for_bandwidth?: number; +}; + +export type RawTronFrozenV2 = { + amount?: number; + type?: string; +}; + +export type RawTronUnfrozenV2 = { + unfreeze_amount: number; + unfreeze_expire_time: number; +}; + +export type RawTronVote = { + vote_address: string; + vote_count: number; +}; + +export type TransactionInfo = { + ret: TransactionResult[]; + signature: string[]; + txID: string; + net_usage: number; + raw_data_hex: string; + net_fee: number; + energy_usage: number; + blockNumber: number; + block_timestamp: number; + energy_fee: number; + energy_usage_total: number; + raw_data: RawTransactionData; + internal_transactions: InternalTransaction[]; +}; + +export type TransactionResult = { + contractRet: string; + fee: number; +}; + +export type RawTransactionData = { + contract: ContractInfo[]; + ref_block_bytes: string; + ref_block_hash: string; + expiration: number; + timestamp?: number; + fee_limit?: number; +}; + +// Specific contract types +export type TransferContractInfo = { + parameter: TransferContractParameter; + type: 'TransferContract'; +}; + +export type TransferContractParameter = { + value: TransferContractValue; + type_url: 'type.googleapis.com/protocol.TransferContract'; +}; + +export type TransferContractValue = { + amount: number; + owner_address: string; + to_address: string; +}; + +export type TransferAssetContractInfo = { + parameter: TransferAssetContractParameter; + type: 'TransferAssetContract'; +}; + +export type TransferAssetContractParameter = { + value: TransferAssetContractValue; + type_url: 'type.googleapis.com/protocol.TransferAssetContract'; +}; + +export type TransferAssetContractValue = { + amount: number; + asset_name: string; + owner_address: string; + to_address: string; +}; + +// General contract type (catch-all) +export type GeneralContractInfo = { + parameter: ContractParameter; + type: string; +}; + +// Union type for all contract types +export type ContractInfo = + | TransferContractInfo + | TransferAssetContractInfo + | GeneralContractInfo; + +export type ContractParameter = { + value: ContractValue; + type_url: string; +}; + +export type ContractValue = { + owner_address?: string; + to_address?: string; + unfreeze_balance?: number; + votes?: ContractVote[]; + frozen_balance?: number; + data?: string; + contract_address?: string; + call_value?: number; +}; + +export type ContractVote = { + vote_address: string; + vote_count: number; +}; + +export type InternalTransaction = { + internal_tx_id?: string; + data?: InternalTransactionData; + to_address?: string; + from_address?: string; +}; + +export type InternalTransactionData = { + note?: string; + rejected?: boolean; + call_value?: InternalTransactionCallValue; +}; + +export type InternalTransactionCallValue = { + _?: number; + callValue?: number; + tokenId?: string; +}; + +export type ContractTransactionInfo = { + transaction_id: string; + token_info: TokenInfo; + block_timestamp: number; + from: string; + to: string; + type: string; + value: string; +}; + +export type TokenInfo = { + symbol: string; + address: string; + decimals: number; + name: string; +}; + +/** + * TRC20 balance entry from the /v1/accounts/{address}/trc20/balance endpoint. + * Each entry is a record mapping the contract address to its balance. + */ +export type Trc20Balance = Record; diff --git a/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts new file mode 100644 index 00000000..01653e33 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/clients/tronweb/TronWebFactory.ts @@ -0,0 +1,47 @@ +import { TronWeb } from 'tronweb'; + +import type { Network } from '../../constants'; +import type { ConfigProvider } from '../../services/config'; + +export class TronWebFactory { + readonly #configProvider: ConfigProvider; + + constructor({ configProvider }: { configProvider: ConfigProvider }) { + this.#configProvider = configProvider; + } + + /** + * Create a TronWeb client instance for a specific network + * This creates a fresh instance each time, avoiding state management issues + * + * @param network - The network to create the client for + * @param privateKey - Optional private key to configure the client with + * @returns TronWeb instance configured for the specified network + * + * @example + * // With private key for signing transactions + * const tronWeb = factory.createClient(Network.Mainnet, privateKey); + * const transaction = await tronWeb.transactionBuilder.sendTrx(to, amount); + * const signedTx = await tronWeb.trx.sign(transaction); + * + * // Without private key for read-only operations + * const tronWeb = factory.createClient(Network.Mainnet); + * const balance = await tronWeb.trx.getBalance(address); + */ + createClient(network: Network, privateKey?: string): TronWeb { + const config = this.#configProvider.get(); + const { baseUrls } = config.trongridApi; + + const fullHost = baseUrls[network]; + if (!fullHost) { + throw new Error(`No configuration found for network: ${network}`); + } + + const tronWebConfig = { + fullHost, + ...(privateKey && { privateKey }), + }; + + return new TronWeb(tronWebConfig); + } +} diff --git a/merged-packages/tron-wallet-snap/src/constants/index.ts b/merged-packages/tron-wallet-snap/src/constants/index.ts new file mode 100644 index 00000000..e0e3f2d6 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/constants/index.ts @@ -0,0 +1,408 @@ +import { TrxScope } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; + +export const ZERO = BigNumber(0); +export const ACCOUNT_ACTIVATION_FEE_TRX = BigNumber(1); +export const MEMO_FEE_TRX = BigNumber(1); +export const SUN_IN_TRX = 1_000_000; +/** + * 100 TRX + */ +export const FEE_LIMIT = 100_000_000; + +export const NULL_ADDRESS = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb'; +export const CONSENSYS_SR_NODE_ADDRESS = 'TVMwGfdDz58VvM7yTzGMWWSHsmofSxa9jH'; + +export enum Network { + Mainnet = TrxScope.Mainnet, + Nile = TrxScope.Nile, + Shasta = TrxScope.Shasta, +} + +export enum KnownCaip19Id { + TrxMainnet = `${Network.Mainnet}/slip44:195`, + TrxNile = `${Network.Nile}/slip44:195`, + TrxShasta = `${Network.Shasta}/slip44:195`, + + UsdtMainnet = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, + + /** + * Staked Tron + */ + TrxStakedForBandwidthMainnet = `${Network.Mainnet}/slip44:195-staked-for-bandwidth`, + TrxStakedForBandwidthNile = `${Network.Nile}/slip44:195-staked-for-bandwidth`, + TrxStakedForBandwidthShasta = `${Network.Shasta}/slip44:195-staked-for-bandwidth`, + + TrxStakedForEnergyMainnet = `${Network.Mainnet}/slip44:195-staked-for-energy`, + TrxStakedForEnergyNile = `${Network.Nile}/slip44:195-staked-for-energy`, + TrxStakedForEnergyShasta = `${Network.Shasta}/slip44:195-staked-for-energy`, + + /** + * TRX Ready for Withdrawal (unstaked TRX that has completed the withdrawal period) + */ + TrxReadyForWithdrawalMainnet = `${Network.Mainnet}/slip44:195-ready-for-withdrawal`, + TrxReadyForWithdrawalNile = `${Network.Nile}/slip44:195-ready-for-withdrawal`, + TrxReadyForWithdrawalShasta = `${Network.Shasta}/slip44:195-ready-for-withdrawal`, + + /** + * Staking Rewards TRX + */ + TrxStakingRewardsMainnet = `${Network.Mainnet}/slip44:195-staking-rewards`, + TrxStakingRewardsNile = `${Network.Nile}/slip44:195-staking-rewards`, + TrxStakingRewardsShasta = `${Network.Shasta}/slip44:195-staking-rewards`, + + /** + * TRX in Lock Period (unstaked but waiting for lock period to end) + */ + TrxInLockPeriodMainnet = `${Network.Mainnet}/slip44:195-in-lock-period`, + TrxInLockPeriodNile = `${Network.Nile}/slip44:195-in-lock-period`, + TrxInLockPeriodShasta = `${Network.Shasta}/slip44:195-in-lock-period`, + + /** + * Tron Resource Assets + */ + EnergyMainnet = `${Network.Mainnet}/slip44:energy`, + EnergyNile = `${Network.Nile}/slip44:energy`, + EnergyShasta = `${Network.Shasta}/slip44:energy`, + + MaximumEnergyMainnet = `${Network.Mainnet}/slip44:maximum-energy`, + MaximumEnergyNile = `${Network.Nile}/slip44:maximum-energy`, + MaximumEnergyShasta = `${Network.Shasta}/slip44:maximum-energy`, + + BandwidthMainnet = `${Network.Mainnet}/slip44:bandwidth`, + BandwidthNile = `${Network.Nile}/slip44:bandwidth`, + BandwidthShasta = `${Network.Shasta}/slip44:bandwidth`, + + MaximumBandwidthMainnet = `${Network.Mainnet}/slip44:maximum-bandwidth`, + MaximumBandwidthNile = `${Network.Nile}/slip44:maximum-bandwidth`, + MaximumBandwidthShasta = `${Network.Shasta}/slip44:maximum-bandwidth`, +} + +export const TRX_METADATA = { + fungible: true as const, + name: 'Tron', + symbol: 'TRX', + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const TRX_STAKED_FOR_BANDWIDTH_METADATA = { + name: 'Staked for Bandwidth', + symbol: 'sTRX-BANDWIDTH', + fungible: true as const, + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const TRX_STAKED_FOR_ENERGY_METADATA = { + name: 'Staked for Energy', + symbol: 'sTRX-ENERGY', + fungible: true as const, + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const TRX_READY_FOR_WITHDRAWAL_METADATA = { + name: 'Ready for Withdrawal', + symbol: 'trx-ready-for-withdrawal', + fungible: true as const, + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const TRX_STAKING_REWARDS_METADATA = { + name: 'Staking Rewards', + symbol: 'trx-staking-rewards', + fungible: true as const, + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const TRX_IN_LOCK_PERIOD_METADATA = { + name: 'In Lock Period', + symbol: 'trx-in-lock-period', + fungible: true as const, + decimals: 6, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const BANDWIDTH_METADATA = { + name: 'Bandwidth', + symbol: 'BANDWIDTH', + fungible: true as const, + decimals: 0, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const MAX_BANDWIDTH_METADATA = { + name: 'Max Bandwidth', + symbol: 'MAX-BANDWIDTH', + fungible: true as const, + decimals: 0, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const ENERGY_METADATA = { + name: 'Energy', + symbol: 'ENERGY', + fungible: true as const, + decimals: 0, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const MAX_ENERGY_METADATA = { + name: 'Max Energy', + symbol: 'MAX-ENERGY', + fungible: true as const, + decimals: 0, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', +}; + +export const TokenMetadata = { + [KnownCaip19Id.TrxMainnet]: { + id: KnownCaip19Id.TrxMainnet, + ...TRX_METADATA, + }, + [KnownCaip19Id.TrxNile]: { + id: KnownCaip19Id.TrxNile, + ...TRX_METADATA, + }, + [KnownCaip19Id.TrxShasta]: { + id: KnownCaip19Id.TrxShasta, + ...TRX_METADATA, + }, + /** + * Tron Staked for Bandwidth Metadata + */ + [KnownCaip19Id.TrxStakedForBandwidthMainnet]: { + id: KnownCaip19Id.TrxStakedForBandwidthMainnet, + ...TRX_STAKED_FOR_BANDWIDTH_METADATA, + }, + [KnownCaip19Id.TrxStakedForBandwidthNile]: { + id: KnownCaip19Id.TrxStakedForBandwidthNile, + ...TRX_STAKED_FOR_BANDWIDTH_METADATA, + }, + [KnownCaip19Id.TrxStakedForBandwidthShasta]: { + id: KnownCaip19Id.TrxStakedForBandwidthShasta, + ...TRX_STAKED_FOR_BANDWIDTH_METADATA, + }, + /** + * Tron Staked for Energy Metadata + */ + [KnownCaip19Id.TrxStakedForEnergyMainnet]: { + id: KnownCaip19Id.TrxStakedForEnergyMainnet, + ...TRX_STAKED_FOR_ENERGY_METADATA, + }, + [KnownCaip19Id.TrxStakedForEnergyNile]: { + id: KnownCaip19Id.TrxStakedForEnergyNile, + ...TRX_STAKED_FOR_ENERGY_METADATA, + }, + [KnownCaip19Id.TrxStakedForEnergyShasta]: { + id: KnownCaip19Id.TrxStakedForEnergyShasta, + ...TRX_STAKED_FOR_ENERGY_METADATA, + }, + /** + * TRX Ready for Withdrawal Metadata + */ + [KnownCaip19Id.TrxReadyForWithdrawalMainnet]: { + id: KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ...TRX_READY_FOR_WITHDRAWAL_METADATA, + }, + [KnownCaip19Id.TrxReadyForWithdrawalNile]: { + id: KnownCaip19Id.TrxReadyForWithdrawalNile, + ...TRX_READY_FOR_WITHDRAWAL_METADATA, + }, + [KnownCaip19Id.TrxReadyForWithdrawalShasta]: { + id: KnownCaip19Id.TrxReadyForWithdrawalShasta, + ...TRX_READY_FOR_WITHDRAWAL_METADATA, + }, + /** + * Staking Rewards Metadata + */ + [KnownCaip19Id.TrxStakingRewardsMainnet]: { + id: KnownCaip19Id.TrxStakingRewardsMainnet, + ...TRX_STAKING_REWARDS_METADATA, + }, + [KnownCaip19Id.TrxStakingRewardsNile]: { + id: KnownCaip19Id.TrxStakingRewardsNile, + ...TRX_STAKING_REWARDS_METADATA, + }, + [KnownCaip19Id.TrxStakingRewardsShasta]: { + id: KnownCaip19Id.TrxStakingRewardsShasta, + ...TRX_STAKING_REWARDS_METADATA, + }, + /** + * TRX In Lock Period Metadata + */ + [KnownCaip19Id.TrxInLockPeriodMainnet]: { + id: KnownCaip19Id.TrxInLockPeriodMainnet, + ...TRX_IN_LOCK_PERIOD_METADATA, + }, + [KnownCaip19Id.TrxInLockPeriodNile]: { + id: KnownCaip19Id.TrxInLockPeriodNile, + ...TRX_IN_LOCK_PERIOD_METADATA, + }, + [KnownCaip19Id.TrxInLockPeriodShasta]: { + id: KnownCaip19Id.TrxInLockPeriodShasta, + ...TRX_IN_LOCK_PERIOD_METADATA, + }, + /** + * Bandwidth Resource Metadata + */ + [KnownCaip19Id.BandwidthMainnet]: { + id: KnownCaip19Id.BandwidthMainnet, + ...BANDWIDTH_METADATA, + }, + [KnownCaip19Id.BandwidthNile]: { + id: KnownCaip19Id.BandwidthNile, + ...BANDWIDTH_METADATA, + }, + [KnownCaip19Id.BandwidthShasta]: { + id: KnownCaip19Id.BandwidthShasta, + ...BANDWIDTH_METADATA, + }, + /** + * Max Bandwidth Metadata + */ + [KnownCaip19Id.MaximumBandwidthMainnet]: { + id: KnownCaip19Id.MaximumBandwidthMainnet, + ...MAX_BANDWIDTH_METADATA, + }, + [KnownCaip19Id.MaximumBandwidthNile]: { + id: KnownCaip19Id.MaximumBandwidthNile, + ...MAX_BANDWIDTH_METADATA, + }, + [KnownCaip19Id.MaximumBandwidthShasta]: { + id: KnownCaip19Id.MaximumBandwidthShasta, + ...MAX_BANDWIDTH_METADATA, + }, + /** + * Energy Resource Metadata + */ + [KnownCaip19Id.EnergyMainnet]: { + id: KnownCaip19Id.EnergyMainnet, + ...ENERGY_METADATA, + }, + [KnownCaip19Id.EnergyNile]: { + id: KnownCaip19Id.EnergyNile, + ...ENERGY_METADATA, + }, + [KnownCaip19Id.EnergyShasta]: { + id: KnownCaip19Id.EnergyShasta, + ...ENERGY_METADATA, + }, + /** + * Max Energy Metadata + */ + [KnownCaip19Id.MaximumEnergyMainnet]: { + id: KnownCaip19Id.MaximumEnergyMainnet, + ...MAX_ENERGY_METADATA, + }, + [KnownCaip19Id.MaximumEnergyNile]: { + id: KnownCaip19Id.MaximumEnergyNile, + ...MAX_ENERGY_METADATA, + }, + [KnownCaip19Id.MaximumEnergyShasta]: { + id: KnownCaip19Id.MaximumEnergyShasta, + ...MAX_ENERGY_METADATA, + }, +} as const; + +export const Networks = { + [Network.Mainnet]: { + caip2Id: Network.Mainnet, + cluster: 'mainnet', + name: 'Tron Mainnet', + nativeToken: TokenMetadata[KnownCaip19Id.TrxMainnet], + stakedForBandwidth: + TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthMainnet], + stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyMainnet], + readyForWithdrawal: + TokenMetadata[KnownCaip19Id.TrxReadyForWithdrawalMainnet], + stakingRewards: TokenMetadata[KnownCaip19Id.TrxStakingRewardsMainnet], + inLockPeriod: TokenMetadata[KnownCaip19Id.TrxInLockPeriodMainnet], + bandwidth: TokenMetadata[KnownCaip19Id.BandwidthMainnet], + maximumBandwidth: TokenMetadata[KnownCaip19Id.MaximumBandwidthMainnet], + energy: TokenMetadata[KnownCaip19Id.EnergyMainnet], + maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyMainnet], + }, + [Network.Nile]: { + caip2Id: Network.Nile, + cluster: 'devnet', + name: 'Tron Nile', + nativeToken: TokenMetadata[KnownCaip19Id.TrxNile], + stakedForBandwidth: TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthNile], + stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyNile], + readyForWithdrawal: TokenMetadata[KnownCaip19Id.TrxReadyForWithdrawalNile], + stakingRewards: TokenMetadata[KnownCaip19Id.TrxStakingRewardsNile], + inLockPeriod: TokenMetadata[KnownCaip19Id.TrxInLockPeriodNile], + bandwidth: TokenMetadata[KnownCaip19Id.BandwidthNile], + maximumBandwidth: TokenMetadata[KnownCaip19Id.MaximumBandwidthNile], + energy: TokenMetadata[KnownCaip19Id.EnergyNile], + maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyNile], + }, + [Network.Shasta]: { + caip2Id: Network.Shasta, + cluster: 'testnet', + name: 'Tron Shasta', + nativeToken: TokenMetadata[KnownCaip19Id.TrxShasta], + stakedForBandwidth: + TokenMetadata[KnownCaip19Id.TrxStakedForBandwidthShasta], + stakedForEnergy: TokenMetadata[KnownCaip19Id.TrxStakedForEnergyShasta], + readyForWithdrawal: + TokenMetadata[KnownCaip19Id.TrxReadyForWithdrawalShasta], + stakingRewards: TokenMetadata[KnownCaip19Id.TrxStakingRewardsShasta], + inLockPeriod: TokenMetadata[KnownCaip19Id.TrxInLockPeriodShasta], + bandwidth: TokenMetadata[KnownCaip19Id.BandwidthShasta], + maximumBandwidth: TokenMetadata[KnownCaip19Id.MaximumBandwidthShasta], + energy: TokenMetadata[KnownCaip19Id.EnergyShasta], + maximumEnergy: TokenMetadata[KnownCaip19Id.MaximumEnergyShasta], + }, +} as const; + +export const SPECIAL_ASSETS: string[] = [ + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForBandwidthNile, + KnownCaip19Id.TrxStakedForBandwidthShasta, + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxStakedForEnergyNile, + KnownCaip19Id.TrxStakedForEnergyShasta, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + KnownCaip19Id.TrxReadyForWithdrawalNile, + KnownCaip19Id.TrxReadyForWithdrawalShasta, + KnownCaip19Id.TrxStakingRewardsMainnet, + KnownCaip19Id.TrxStakingRewardsNile, + KnownCaip19Id.TrxStakingRewardsShasta, + KnownCaip19Id.TrxInLockPeriodMainnet, + KnownCaip19Id.TrxInLockPeriodNile, + KnownCaip19Id.TrxInLockPeriodShasta, + KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.BandwidthNile, + KnownCaip19Id.BandwidthShasta, + KnownCaip19Id.MaximumBandwidthMainnet, + KnownCaip19Id.MaximumBandwidthNile, + KnownCaip19Id.MaximumBandwidthShasta, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.EnergyNile, + KnownCaip19Id.EnergyShasta, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.MaximumEnergyNile, + KnownCaip19Id.MaximumEnergyShasta, +]; + +export const ESSENTIAL_ASSETS: string[] = [ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxNile, + KnownCaip19Id.TrxShasta, + ...SPECIAL_ASSETS, +]; diff --git a/merged-packages/tron-wallet-snap/src/context.ts b/merged-packages/tron-wallet-snap/src/context.ts new file mode 100644 index 00000000..20c8b42c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/context.ts @@ -0,0 +1,290 @@ +import { InMemoryCache } from './caching/InMemoryCache'; +import { StateCache } from './caching/StateCache'; +import { PriceApiClient } from './clients/price-api/PriceApiClient'; +import { SecurityAlertsApiClient } from './clients/security-alerts-api/SecurityAlertsApiClient'; +import { SnapClient } from './clients/snap/SnapClient'; +import { TokenApiClient } from './clients/token-api/TokenApiClient'; +import { TronHttpClient } from './clients/tron-http/TronHttpClient'; +import { TrongridApiClient } from './clients/trongrid/TrongridApiClient'; +import { TronWebFactory } from './clients/tronweb/TronWebFactory'; +import { AssetsHandler } from './handlers/assets'; +import { ClientRequestHandler } from './handlers/clientRequest/clientRequest'; +import { CronHandler } from './handlers/cronjob'; +import { KeyringHandler } from './handlers/keyring'; +import { RpcHandler } from './handlers/rpc/rpc'; +import { UserInputHandler } from './handlers/userInput'; +import { AccountsRepository } from './services/accounts/AccountsRepository'; +import { AccountsService } from './services/accounts/AccountsService'; +import { AssetsRepository } from './services/assets/AssetsRepository'; +import { AssetsService } from './services/assets/AssetsService'; +import { ConfigProvider } from './services/config'; +import { ConfirmationHandler } from './services/confirmation/ConfirmationHandler'; +import { FeeCalculatorService } from './services/send/FeeCalculatorService'; +import { SendService } from './services/send/SendService'; +import { StakingService } from './services/staking/StakingService'; +import type { UnencryptedStateValue } from './services/state/State'; +import { State } from './services/state/State'; +import { TransactionExpirationRefresherService } from './services/transaction-expiration-refresher/TransactionExpirationRefresherService'; +import { TransactionScanService } from './services/transaction-scan/TransactionScanService'; +import { TransactionsRepository } from './services/transactions/TransactionsRepository'; +import { TransactionsService } from './services/transactions/TransactionsService'; +import { WalletService } from './services/wallet/WalletService'; +import logger, { noOpLogger } from './utils/logger'; + +/** + * Services + * + * Dependency injection order: + * 1. Core services (ConfigProvider, State, Connection) + * 2. Repositories (AssetsRepository, TransactionsRepository, AccountsRepository) + * 3. Business services (AssetsService, TransactionsService, AccountsService) + * 4. Handlers (AssetsHandler, CronHandler, KeyringHandler, RpcHandler, UserInputHandler) + */ +export const configProvider = new ConfigProvider(); + +const state = new State({ + encrypted: false, + defaultState: { + keyringAccounts: {}, + assets: {}, + tokenPrices: {}, + transactions: {}, + mapInterfaceNameToId: {}, + }, +}); + +const snapClient = new SnapClient({ logger }); + +// Repositories - depend on State +const accountsRepository = new AccountsRepository(state); +const assetsRepository = new AssetsRepository(state); +const transactionsRepository = new TransactionsRepository(state); + +// Clients +const tronHttpClient = new TronHttpClient({ configProvider }); + +// Cache for TrongridApiClient (chain parameters caching) +// Using StateCache to persist cache across Snap restarts +const trongridCache = new StateCache(state, noOpLogger, '__cache__trongrid'); +const trongridApiClient = new TrongridApiClient({ + configProvider, + tronHttpClient, + cache: trongridCache, +}); +const tronWebFactory = new TronWebFactory({ + configProvider, +}); + +// Cache for PriceApiClient +const priceCache = new InMemoryCache(noOpLogger); +const priceApiClient = new PriceApiClient(configProvider, priceCache); + +// Token API client +const tokenApiClient = new TokenApiClient(configProvider); + +// Security Alerts API client +const securityAlertsApiClient = new SecurityAlertsApiClient( + configProvider, + logger, +); + +// Business Services - depend on Repositories, State and other Services +const assetsService = new AssetsService({ + logger, + state, + assetsRepository, + trongridApiClient, + tronHttpClient, + priceApiClient, + tokenApiClient, + snapClient, +}); + +const transactionsService = new TransactionsService({ + logger, + transactionsRepository, + trongridApiClient, + tronHttpClient, +}); + +const accountsService = new AccountsService({ + logger, + snapClient, + accountsRepository, + configProvider, + assetsService, + transactionsService, +}); + +const feeCalculatorService = new FeeCalculatorService({ + logger, + trongridApiClient, + tronHttpClient, +}); + +const transactionExpirationRefresherService = + new TransactionExpirationRefresherService({ + tronWebFactory, + }); + +const sendService = new SendService({ + logger, + snapClient, + accountsService, + assetsService, + tronWebFactory, + feeCalculatorService, + transactionExpirationRefresherService, +}); + +const stakingService = new StakingService({ + logger, + snapClient, + accountsService, + tronWebFactory, +}); + +const walletService = new WalletService({ + logger, + accountsService, + tronWebFactory, + transactionExpirationRefresherService, +}); + +const transactionScanService = new TransactionScanService( + securityAlertsApiClient, + snapClient, + logger, +); + +const confirmationHandler = new ConfirmationHandler({ + snapClient, + state, + tronWebFactory, + assetsService, + feeCalculatorService, +}); + +/** + * Handlers + */ +const assetsHandler = new AssetsHandler({ + logger, + assetsService, +}); +const clientRequestHandler = new ClientRequestHandler({ + logger, + snapClient, + accountsService, + assetsService, + sendService, + tronWebFactory, + feeCalculatorService, + stakingService, + confirmationHandler, + transactionsService, + transactionExpirationRefresherService, +}); +const cronHandler = new CronHandler({ + logger, + snapClient, + accountsService, + state, + priceApiClient, + tronHttpClient, + transactionScanService, + transactionExpirationRefresherService, +}); +const keyringHandler = new KeyringHandler({ + logger, + snapClient, + accountsService, + assetsService, + transactionsService, + walletService, + confirmationHandler, + transactionExpirationRefresherService, +}); +const rpcHandler = new RpcHandler({ + logger, +}); +const userInputHandler = new UserInputHandler({ + logger, + snapClient, +}); + +export type SnapExecutionContext = { + /** + * Clients + */ + snapClient: SnapClient; + /** + * Services + */ + state: State; + priceApiClient: PriceApiClient; + feeCalculatorService: FeeCalculatorService; + assetsService: AssetsService; + accountsService: AccountsService; + transactionsService: TransactionsService; + sendService: SendService; + walletService: WalletService; + tronHttpClient: TronHttpClient; + tronWebFactory: TronWebFactory; + confirmationHandler: ConfirmationHandler; + transactionScanService: TransactionScanService; + /** + * Handlers + */ + assetsHandler: AssetsHandler; + cronHandler: CronHandler; + clientRequestHandler: ClientRequestHandler; + keyringHandler: KeyringHandler; + rpcHandler: RpcHandler; + userInputHandler: UserInputHandler; +}; + +const snapContext: SnapExecutionContext = { + /** + * Clients + */ + snapClient, + /** + * Services + */ + state, + priceApiClient, + feeCalculatorService, + assetsService, + accountsService, + transactionsService, + sendService, + walletService, + tronHttpClient, + tronWebFactory, + confirmationHandler, + transactionScanService, + /** + * Handlers + */ + assetsHandler, + clientRequestHandler, + cronHandler, + keyringHandler, + rpcHandler, + userInputHandler, +}; + +export { + /** + * Handlers + */ + assetsHandler, + clientRequestHandler, + cronHandler, + keyringHandler, + rpcHandler, + userInputHandler, +}; + +export default snapContext; diff --git a/merged-packages/tron-wallet-snap/src/entities/assets.ts b/merged-packages/tron-wallet-snap/src/entities/assets.ts new file mode 100644 index 00000000..76d0e4d6 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/entities/assets.ts @@ -0,0 +1,79 @@ +import type { KnownCaip19Id, Network } from '../constants'; +import type { + InLockPeriodCaipAssetType, + NativeCaipAssetType, + NftCaipAssetType, + ReadyForWithdrawalCaipAssetType, + StakingRewardsCaipAssetType, + TokenCaipAssetType, +} from '../services/assets/types'; + +type BaseAsset = { + assetType: string; + keyringAccountId: string; + network: Network; + symbol: string; + decimals: number; + rawAmount: string; // Without decimals + uiAmount: string; // With decimals + iconUrl: string; // Asset icon URL +}; + +export type NativeAsset = BaseAsset & { + assetType: NativeCaipAssetType; +}; + +export type StakedAsset = BaseAsset & { + assetType: `${Network}/slip44:195-staked-for-${'energy' | 'bandwidth'}`; +}; + +export type ReadyForWithdrawalAsset = BaseAsset & { + assetType: ReadyForWithdrawalCaipAssetType; +}; + +export type StakingRewardsAsset = BaseAsset & { + assetType: StakingRewardsCaipAssetType; +}; + +export type InLockPeriodAsset = BaseAsset & { + assetType: InLockPeriodCaipAssetType; +}; + +export type ResourceAsset = BaseAsset & { + assetType: + | KnownCaip19Id.EnergyMainnet + | KnownCaip19Id.EnergyNile + | KnownCaip19Id.EnergyShasta + | KnownCaip19Id.BandwidthMainnet + | KnownCaip19Id.BandwidthNile + | KnownCaip19Id.BandwidthShasta; +}; + +export type MaximumResourceAsset = BaseAsset & { + assetType: + | KnownCaip19Id.MaximumEnergyMainnet + | KnownCaip19Id.MaximumEnergyNile + | KnownCaip19Id.MaximumEnergyShasta + | KnownCaip19Id.MaximumBandwidthMainnet + | KnownCaip19Id.MaximumBandwidthNile + | KnownCaip19Id.MaximumBandwidthShasta; +}; + +export type TokenAsset = BaseAsset & { + assetType: TokenCaipAssetType; // Using the mint +}; + +export type NftAsset = BaseAsset & { + assetType: NftCaipAssetType; +}; + +export type AssetEntity = + | NativeAsset + | StakedAsset + | ReadyForWithdrawalAsset + | StakingRewardsAsset + | InLockPeriodAsset + | TokenAsset + | NftAsset + | ResourceAsset + | MaximumResourceAsset; diff --git a/merged-packages/tron-wallet-snap/src/entities/keyring-account.ts b/merged-packages/tron-wallet-snap/src/entities/keyring-account.ts new file mode 100644 index 00000000..b3c0b24d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/entities/keyring-account.ts @@ -0,0 +1,31 @@ +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; + +/** + * We need to store the index of the KeyringAccount in the state because + * we want to be able to restore any account with a previously used index. + */ +export type TronKeyringAccount = KeyringAccount & { + entropySource: EntropySourceId; + derivationPath: `m/${string}`; + index: number; +}; + +/** + * Converts a Tron keyring account to its stricter form (required by the Keyring API). + * + * @param account - A Tron keyring account. + * @returns A strict keyring account (with no additional fields). + */ +export function asStrictKeyringAccount( + account: TronKeyringAccount, +): KeyringAccount { + const { id, address, type, options, methods, scopes } = account; + return { + id, + address, + type, + options, + methods, + scopes, + }; +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/assets.ts b/merged-packages/tron-wallet-snap/src/handlers/assets.ts new file mode 100644 index 00000000..d0d72324 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/assets.ts @@ -0,0 +1,81 @@ +import type { + OnAssetHistoricalPriceArguments, + OnAssetHistoricalPriceResponse, + OnAssetsConversionArguments, + OnAssetsConversionResponse, + OnAssetsLookupArguments, + OnAssetsLookupResponse, + OnAssetsMarketDataArguments, + OnAssetsMarketDataResponse, +} from '@metamask/snaps-sdk'; + +import type { AssetsService } from '../services/assets/AssetsService'; +import type { ILogger } from '../utils/logger'; +import { createPrefixedLogger } from '../utils/logger'; + +export class AssetsHandler { + readonly #logger: ILogger; + + readonly #assetsService: AssetsService; + + constructor({ + logger, + assetsService, + }: { + logger: ILogger; + assetsService: AssetsService; + }) { + this.#logger = createPrefixedLogger(logger, '[🪙 AssetsHandler]'); + this.#assetsService = assetsService; + } + + async onAssetHistoricalPrice( + params: OnAssetHistoricalPriceArguments, + ): Promise { + this.#logger.log('[📈 onAssetHistoricalPrice]', params); + + const { from, to } = params; + + const historicalPrice = await this.#assetsService.getHistoricalPrice( + from, + to, + ); + + return { + historicalPrice, + }; + } + + async onAssetsConversion( + params: OnAssetsConversionArguments, + ): Promise { + this.#logger.log('[💱 onAssetsConversion]'); + + const { conversions } = params; + + const conversionRates = + await this.#assetsService.getMultipleTokenConversions(conversions); + + return { + conversionRates, + }; + } + + async onAssetsLookup( + params: OnAssetsLookupArguments, + ): Promise { + const assets = await this.#assetsService.getAssetsMetadata(params.assets); + + return { assets }; + } + + async onAssetsMarketData( + params: OnAssetsMarketDataArguments, + ): Promise { + const marketData = await this.#assetsService.getMultipleTokensMarketData( + params.assets, + ); + + return { marketData }; + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts new file mode 100644 index 00000000..1ce03fc9 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts @@ -0,0 +1,2604 @@ +import { FeeType } from '@metamask/keyring-api'; +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; +import type { Infer } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; +import { TronWeb } from 'tronweb'; +import type { Transaction, TransferContract } from 'tronweb/lib/esm/types'; + +import { ClientRequestHandler } from './clientRequest'; +import { ClientRequestMethod, SendErrorCodes } from './types'; +import type { OnAmountInputRequestStruct } from './validation'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import { FEE_LIMIT, Network, Networks } from '../../constants'; +import type { NativeAsset, ResourceAsset } from '../../entities/assets'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import type { AccountsService } from '../../services/accounts/AccountsService'; +import type { AssetsService } from '../../services/assets/AssetsService'; +import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; +import type { FeeCalculatorService } from '../../services/send/FeeCalculatorService'; +import type { SendService } from '../../services/send/SendService'; +import type { ComputeFeeResult } from '../../services/send/types'; +import type { StakingService } from '../../services/staking/StakingService'; +import { TransactionExpirationRefresherService } from '../../services/transaction-expiration-refresher/TransactionExpirationRefresherService'; +import type { TransactionsService } from '../../services/transactions/TransactionsService'; +import { trxToSun } from '../../utils/conversion'; +import { mockLogger } from '../../utils/mockLogger'; + +const createPassThroughTransactionExpirationRefresherService = () => + ({ + ensureFreshMetadata: jest.fn( + async ({ + transaction, + }: { + transaction: TransactionType; + }) => transaction, + ), + ensureFreshRawData: jest.fn(async ({ rawData }) => rawData), + }) as unknown as TransactionExpirationRefresherService; + +type WithClientRequestHandlerCallback = (payload: { + handler: ClientRequestHandler; + mockAccountsService: jest.Mocked>; + mockAssetsService: jest.Mocked>; + mockSendService: jest.Mocked>; + mockFeeCalculatorService: jest.Mocked< + Pick + >; + mockSnapClient: jest.Mocked>; +}) => Promise | ReturnValue; + +/** + * Wraps tests by creating a fresh handler and fresh mocks. + * + * @param testFunction - The test body receiving the handler and relevant mocks. + * @returns The return value of the callback. + */ +async function withClientRequestHandler( + testFunction: WithClientRequestHandlerCallback, +): Promise { + const mockAccountsService: jest.Mocked> = { + findById: jest.fn(), + }; + + const mockAssetsService: jest.Mocked< + Pick + > = { + getAssetsByAccountId: jest.fn(), + }; + + const mockSendService: jest.Mocked> = { + buildTransaction: jest.fn(), + }; + + const mockFeeCalculatorService: jest.Mocked< + Pick + > = { + computeFee: jest.fn(), + }; + + const mockSnapClient: jest.Mocked> = { + trackError: jest.fn(), + }; + + const handler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService as unknown as AccountsService, + assetsService: mockAssetsService as unknown as AssetsService, + sendService: mockSendService as unknown as SendService, + feeCalculatorService: + mockFeeCalculatorService as unknown as FeeCalculatorService, + tronWebFactory: {} as TronWebFactory, + snapClient: mockSnapClient as unknown as SnapClient, + stakingService: {} as StakingService, + confirmationHandler: {} as ConfirmationHandler, + transactionsService: {} as TransactionsService, + transactionExpirationRefresherService: + createPassThroughTransactionExpirationRefresherService(), + }); + + return await testFunction({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + mockSnapClient, + }); +} + +describe('ClientRequestHandler', () => { + describe('computeFee', () => { + let clientRequestHandler: ClientRequestHandler; + let mockAccountsService: jest.Mocked; + let mockAssetsService: jest.Mocked; + let mockSendService: jest.Mocked; + let mockFeeCalculatorService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockStakingService: jest.Mocked; + let mockConfirmationHandler: jest.Mocked; + let mockTronWeb: any; + let mockTransactionsService: jest.Mocked; + let mockTransactionExpirationRefresherService: { + ensureFreshMetadata: jest.Mock; + }; + + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + const TEST_TRANSACTION_BASE64 = + 'CgK0FiII40phBu42/OZAkJyY96YzWrADCB8SqwMKMXR5cGUuZ29vZ2xlYXBpcy5jb20vcHJvdG9jb2wuVHJpZ2dlclNtYXJ0Q29udHJhY3QS9QIKFUEU0B62M0bakw7g2jDVhRrBTODEeRIVQZlGn9WqCM/oNjlc6ZPA69Vn4sFPIsQCnd+TuwAAAAAAAAAAAAAAAKYU+AO2/XgJhqQseOycf3fm3tE8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC+vCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnq6OQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF1RSWHxzeWtuZjl8MC41fGJyaWRnZXJzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACJUQnNGcUttbVJ5WWNuWUphOFlEeFk1ZDJKQVJhSGVxdUJKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcLzdlPemMw=='; + + const createBlock = ({ + number, + timestamp, + hashSegment = '1122334455667788', + }: { + number: number; + timestamp: number; + hashSegment?: string; + }) => ({ + blockID: `${'0'.repeat(16)}${hashSegment}${'f'.repeat(32)}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + block_header: { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + number, + timestamp, + }, + }, + }); + + const getRefBlockBytes = (number: number) => + number.toString(16).slice(-4).padStart(4, '0'); + + beforeEach(() => { + mockAccountsService = { + findByIdOrThrow: jest.fn(), + deriveTronKeypair: jest.fn(), + } as unknown as jest.Mocked; + + mockAssetsService = { + getAssetsByAccountId: jest.fn(), + } as unknown as jest.Mocked; + + mockSendService = {} as unknown as jest.Mocked; + + mockFeeCalculatorService = { + computeFee: jest.fn(), + } as unknown as jest.Mocked; + + mockTronWeb = { + utils: { + deserializeTx: { + deserializeTransaction: jest.fn(), + }, + transaction: { + txJsonToPb: jest.fn().mockImplementation((tx) => tx), + txPbToRawDataHex: jest.fn().mockReturnValue('1234567890abcdef'), + txPbToTxID: jest.fn().mockReturnValue('mock-tx-id'), + }, + }, + trx: { + getCurrentBlock: jest.fn().mockResolvedValue( + createBlock({ + number: 200_000, + timestamp: Date.now(), + }), + ), + getBlockByNumber: jest.fn(), + sign: jest.fn(), + sendRawTransaction: jest.fn(), + }, + }; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + } as unknown as jest.Mocked; + + mockSnapClient = { + scheduleBackgroundEvent: jest.fn(), + } as unknown as jest.Mocked; + mockStakingService = {} as unknown as jest.Mocked; + mockConfirmationHandler = + {} as unknown as jest.Mocked; + mockTransactionsService = { + save: jest.fn(), + } as unknown as jest.Mocked; + mockTransactionExpirationRefresherService = { + ensureFreshMetadata: jest.fn( + async ({ + transaction, + }: { + transaction: TransactionType; + }) => transaction, + ), + }; + clientRequestHandler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + sendService: mockSendService, + feeCalculatorService: mockFeeCalculatorService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + stakingService: mockStakingService, + confirmationHandler: mockConfirmationHandler, + transactionsService: mockTransactionsService, + transactionExpirationRefresherService: + mockTransactionExpirationRefresherService as unknown as TransactionExpirationRefresherService, + }); + }); + + describe('when called with valid parameters from external dapp', () => { + it('signs and sends a transaction with the default feeLimit', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignAndSendTransaction, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope, + options: { + visible: false, + type: 'TriggerSmartContract', + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + } as any); + + const mockRawData = { + contract: [ + { + type: 'TriggerSmartContract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: TronWeb.address.toHex( + 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + ), + }, + }, + }, + ], + }; + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue( + mockRawData, + ); + + const signedTransaction = { + txID: 'test-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: mockRawData, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'test-hex', + signature: ['test-signature'], + }; + mockTronWeb.trx.sign.mockResolvedValue(signedTransaction); + mockTronWeb.trx.sendRawTransaction.mockResolvedValue({ + result: true, + txid: 'test-tx-id', + }); + + const result = await clientRequestHandler.handle( + request as JsonRpcRequest, + ); + + expect(result).toStrictEqual({ + transactionId: 'test-tx-id', + }); + + // Verify the default feeLimit was set on rawData before signing + expect(mockRawData).toHaveProperty('fee_limit', FEE_LIMIT); + expect(mockTronWeb.utils.transaction.txJsonToPb).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/naming-convention + fee_limit: FEE_LIMIT, + }), + }), + ); + expect( + mockTronWeb.utils.transaction.txPbToRawDataHex, + ).toHaveBeenCalled(); + expect(mockTronWeb.trx.sign).toHaveBeenCalled(); + }); + + it('refreshes stale transaction metadata before signing and sending an external transaction', async () => { + const currentTimestamp = Date.now(); + const currentBlock = createBlock({ + number: 200_000, + timestamp: currentTimestamp, + hashSegment: '0011223344556677', + }); + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignAndSendTransaction, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope, + options: { + visible: false, + type: 'TriggerSmartContract', + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: [], + } as any); + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + } as any); + + const mockRawData = { + contract: [ + { + type: 'TriggerSmartContract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: TronWeb.address.toHex( + 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + ), + }, + }, + }, + ], + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_bytes: '0000', + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_hash: '0000000000000000', + expiration: currentTimestamp - 1, + timestamp: currentTimestamp - 60_000, + }; + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue( + mockRawData, + ); + mockTronWeb.trx.getCurrentBlock.mockResolvedValue(currentBlock); + mockTronWeb.trx.sign.mockImplementation(async (transaction: any) => ({ + ...transaction, + signature: ['test-signature'], + })); + mockTronWeb.trx.sendRawTransaction.mockResolvedValue({ + result: true, + txid: 'broadcast-tx-id', + }); + const originalRawData = structuredClone(mockRawData); + clientRequestHandler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + sendService: mockSendService, + feeCalculatorService: mockFeeCalculatorService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + stakingService: mockStakingService, + confirmationHandler: mockConfirmationHandler, + transactionsService: mockTransactionsService, + transactionExpirationRefresherService: + new TransactionExpirationRefresherService({ + tronWebFactory: mockTronWebFactory, + }), + }); + + await clientRequestHandler.handle(request as JsonRpcRequest); + + const signedTransaction = mockTronWeb.trx.sign.mock.calls[0]?.[0]; + expect(signedTransaction.raw_data).not.toBe(mockRawData); + expect(signedTransaction.raw_data.ref_block_bytes).toBe( + getRefBlockBytes(200_000), + ); + expect(signedTransaction.raw_data.ref_block_hash).toBe( + '0011223344556677', + ); + expect(signedTransaction.raw_data.expiration).toBe( + currentTimestamp + 60_000, + ); + expect(signedTransaction.raw_data.timestamp).toBe(currentTimestamp); + expect(signedTransaction.raw_data_hex).toBe('1234567890abcdef'); + expect(signedTransaction.txID).toBe('mock-tx-id'); + expect(mockRawData.ref_block_bytes).toBe( + originalRawData.ref_block_bytes, + ); + expect(mockRawData.ref_block_hash).toBe(originalRawData.ref_block_hash); + expect(mockRawData.expiration).toBe(originalRawData.expiration); + expect(mockRawData.timestamp).toBe(originalRawData.timestamp); + }); + + it('signs the external transaction returned by the injected expiration refresher', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignAndSendTransaction, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope, + options: { + visible: false, + type: 'TriggerSmartContract', + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: [], + } as any); + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + } as any); + + const mockRawData = { + contract: [ + { + type: 'TriggerSmartContract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: TronWeb.address.toHex( + 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + ), + }, + }, + }, + ], + }; + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue( + mockRawData, + ); + const freshTransaction = { + visible: false, + txID: 'fresh-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: mockRawData, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'fresh-raw-data-hex', + }; + mockTransactionExpirationRefresherService.ensureFreshMetadata.mockResolvedValue( + freshTransaction, + ); + mockTronWeb.trx.sign.mockImplementation(async (transaction: any) => ({ + ...transaction, + signature: ['test-signature'], + })); + mockTronWeb.trx.sendRawTransaction.mockResolvedValue({ + result: true, + txid: 'broadcast-tx-id', + }); + + await clientRequestHandler.handle(request as JsonRpcRequest); + + expect( + mockTransactionExpirationRefresherService.ensureFreshMetadata, + ).toHaveBeenCalledWith( + expect.objectContaining({ + scope, + transaction: expect.objectContaining({ + txID: expect.any(String), + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: mockRawData, + }), + }), + ); + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(freshTransaction); + }); + + it('computes fee breakdown for TRC20 transfer transaction', async () => { + const scope = Network.Shasta; + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.ComputeFee, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope, + options: { + visible: false, + type: 'TriggerSmartContract', + }, + }, + }; + + // Mock account lookup + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + // Mock keypair derivation + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + } as any); + + // Mock transaction deserialization + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({ + contract: [ + { + type: 'TriggerSmartContract', + parameter: { + // eslint-disable-next-line @typescript-eslint/naming-convention + type_url: 'type.googleapis.com/protocol.TriggerSmartContract', + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: '41045d01eb63374da930ee0da30d58516ac14ce04c79', + // eslint-disable-next-line @typescript-eslint/naming-convention + contract_address: '419946f55aa08cfe8363959ce9930ebd567e2c14f', + data: 'a9059cbb000000000000000000000000a614f803b6fd780986a42c78ec9c7f77e6ded13c0000000000000000000000000000000000000000000000000000000000000000', + }, + }, + }, + ], + }); + + // Mock transaction signing + const signedTransaction = { + txID: 'test-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + contract: [ + { + type: 'TriggerSmartContract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: + '41045d01eb63374da930ee0da30d58516ac14ce04c79', + // eslint-disable-next-line @typescript-eslint/naming-convention + contract_address: + '419946f55aa08cfe8363959ce9930ebd567e2c14f', + data: 'a9059cbb', + }, + }, + }, + ], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'test-hex', + signature: ['test-signature'], + }; + mockTronWeb.trx.sign.mockResolvedValue(signedTransaction); + + // Mock available resources + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { rawAmount: '5000' }, // Bandwidth + { rawAmount: '100000' }, // Energy + ] as any); + + // Mock fee calculation result + const feeResult = [ + { + type: FeeType.Base, + asset: { + unit: Networks[scope].energy.symbol, + type: Networks[scope].energy.id, + amount: '65000', + fungible: true as const, + }, + }, + { + type: FeeType.Base, + asset: { + unit: Networks[scope].bandwidth.symbol, + type: Networks[scope].bandwidth.id, + amount: '345', + fungible: true as const, + }, + }, + ]; + mockFeeCalculatorService.computeFee.mockResolvedValue(feeResult); + + // Execute + const result = await clientRequestHandler.handle( + request as JsonRpcRequest, + ); + + // Verify - no signing needed for fee computation + expect(mockAccountsService.findByIdOrThrow).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + ); + // deriveTronKeypair is NOT called - no private key needed for fee computation + expect(mockAccountsService.deriveTronKeypair).not.toHaveBeenCalled(); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith(scope); + expect( + mockTronWeb.utils.deserializeTx.deserializeTransaction, + ).toHaveBeenCalledWith('TriggerSmartContract', expect.any(String)); + // trx.sign is NOT called - fee computation uses unsigned transactions + expect(mockTronWeb.trx.sign).not.toHaveBeenCalled(); + expect(mockAssetsService.getAssetsByAccountId).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + [Networks[scope].bandwidth.id, Networks[scope].energy.id], + ); + // computeFee receives unsigned transaction (no signature field) + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope, + transaction: expect.objectContaining({ + txID: expect.any(String), + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: expect.any(Object), + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: expect.any(String), + visible: false, + }), + availableEnergy: BigNumber('100000'), + availableBandwidth: BigNumber('5000'), + feeLimit: FEE_LIMIT, + }); + expect(result).toStrictEqual(feeResult); + }); + + it('computes fee for native TRX transfer', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: '2', + method: ClientRequestMethod.ComputeFee, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope, + options: { + visible: true, + type: 'TransferContract', + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + } as any); + + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({ + contract: [ + { + type: 'TransferContract', + parameter: { + // eslint-disable-next-line @typescript-eslint/naming-convention + type_url: 'type.googleapis.com/protocol.TransferContract', + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: '41045d01eb63374da930ee0da30d58516ac14ce04c79', + // eslint-disable-next-line @typescript-eslint/naming-convention + to_address: '419946f55aa08cfe8363959ce9930ebd567e2c14f', + amount: 1000000, + }, + }, + }, + ], + }); + + const signedTransaction = { + txID: 'test-tx-id-2', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + contract: [ + { + type: 'TransferContract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: + '41045d01eb63374da930ee0da30d58516ac14ce04c79', + // eslint-disable-next-line @typescript-eslint/naming-convention + to_address: '419946f55aa08cfe8363959ce9930ebd567e2c14f', + amount: 1000000, + }, + }, + }, + ], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'test-hex-2', + signature: ['test-signature-2'], + }; + mockTronWeb.trx.sign.mockResolvedValue(signedTransaction); + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { rawAmount: '1000' }, // Bandwidth + { rawAmount: '0' }, // Energy (not needed for native transfer) + ] as any); + + // Native transfer only uses bandwidth + const feeResult = [ + { + type: FeeType.Base, + asset: { + unit: Networks[scope].bandwidth.symbol, + type: Networks[scope].bandwidth.id, + amount: '268', + fungible: true as const, + }, + }, + ]; + mockFeeCalculatorService.computeFee.mockResolvedValue(feeResult); + + const result = await clientRequestHandler.handle( + request as JsonRpcRequest, + ); + + expect(result).toStrictEqual(feeResult); + // computeFee receives unsigned transaction (no signature field) + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope, + transaction: expect.objectContaining({ + txID: expect.any(String), + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: expect.any(Object), + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: expect.any(String), + }), + availableEnergy: BigNumber('0'), + availableBandwidth: BigNumber('1000'), + feeLimit: FEE_LIMIT, + }); + }); + + it('handles account with no available resources', async () => { + const scope = Network.Nile; + const request = { + jsonrpc: '2.0' as const, + id: '3', + method: ClientRequestMethod.ComputeFee, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope, + options: { + visible: false, + type: 'TriggerSmartContract', + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + } as any); + + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({ + contract: [ + { + type: 'TriggerSmartContract', + parameter: { + // eslint-disable-next-line @typescript-eslint/naming-convention + type_url: 'type.googleapis.com/protocol.TriggerSmartContract', + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: '41045d01eb63374da930ee0da30d58516ac14ce04c79', + // eslint-disable-next-line @typescript-eslint/naming-convention + contract_address: '419946f55aa08cfe8363959ce9930ebd567e2c14f', + }, + }, + }, + ], + }); + mockTronWeb.trx.sign.mockResolvedValue({ + txID: 'test-tx-id-3', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: {}, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'test-hex-3', + signature: [], + }); + + // No resources available + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + undefined, // No bandwidth asset + undefined, // No energy asset + ] as any); + + // When no resources available, user pays in TRX + const feeResult = [ + { + type: FeeType.Base, + asset: { + unit: Networks[scope].nativeToken.symbol, + type: Networks[scope].nativeToken.id, + amount: '13.5', + fungible: true as const, + }, + }, + ]; + mockFeeCalculatorService.computeFee.mockResolvedValue(feeResult); + + const result = await clientRequestHandler.handle( + request as JsonRpcRequest, + ); + + expect(result).toStrictEqual(feeResult); + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope, + transaction: expect.any(Object), + availableEnergy: BigNumber('0'), + availableBandwidth: BigNumber('0'), + feeLimit: FEE_LIMIT, + }); + }); + }); + + describe('when called with invalid parameters', () => { + it('throws InvalidParamsError for missing accountId', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '4', + method: ClientRequestMethod.ComputeFee, + params: { + transaction: TEST_TRANSACTION_BASE64, + scope: Network.Shasta, + options: { + visible: false, + type: 'TransferContract', + }, + }, + }; + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Invalid method parameter(s)'); + }); + + it('throws InvalidParamsError for invalid transaction format', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '5', + method: ClientRequestMethod.ComputeFee, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: 'not-valid-base64!!!', + scope: Network.Shasta, + options: { + visible: false, + type: 'TransferContract', + }, + }, + }; + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Invalid method parameter(s)'); + }); + + it('throws error when account not found', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '6', + method: ClientRequestMethod.ComputeFee, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope: Network.Shasta, + options: { + visible: false, + type: 'TransferContract', + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockRejectedValue( + new Error('Account not found'), + ); + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Account not found'); + }); + }); + }); + + describe('signRewardsMessage', () => { + let clientRequestHandler: ClientRequestHandler; + let mockAccountsService: jest.Mocked; + let mockAssetsService: jest.Mocked; + let mockSendService: jest.Mocked; + let mockFeeCalculatorService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockStakingService: jest.Mocked; + let mockConfirmationHandler: jest.Mocked; + let mockTronWeb: any; + let mockTransactionsService: jest.Mocked; + + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + const TEST_ADDRESS = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const TEST_PRIVATE_KEY = 'test-private-key'; + + /** + * Helper function to convert a utf8 string to base64. + * + * @param utf8 - The utf8 string to convert. + * @returns The base64 encoded string. + */ + const utf8ToBase64 = (utf8: string): string => { + // eslint-disable-next-line no-restricted-globals + return Buffer.from(utf8, 'utf8').toString('base64'); + }; + + beforeEach(() => { + mockAccountsService = { + findById: jest.fn(), + deriveTronKeypair: jest.fn(), + } as unknown as jest.Mocked; + + mockAssetsService = {} as unknown as jest.Mocked; + mockSendService = {} as unknown as jest.Mocked; + mockFeeCalculatorService = + {} as unknown as jest.Mocked; + + mockTronWeb = { + trx: { + signMessageV2: jest.fn(), + }, + }; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + } as unknown as jest.Mocked; + + mockSnapClient = {} as unknown as jest.Mocked; + mockStakingService = {} as unknown as jest.Mocked; + mockConfirmationHandler = + {} as unknown as jest.Mocked; + mockTransactionsService = + {} as unknown as jest.Mocked; + + clientRequestHandler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + sendService: mockSendService, + feeCalculatorService: mockFeeCalculatorService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + stakingService: mockStakingService, + confirmationHandler: mockConfirmationHandler, + transactionsService: mockTransactionsService, + transactionExpirationRefresherService: + createPassThroughTransactionExpirationRefresherService(), + }); + }); + + describe('when called with valid parameters', () => { + it('signs a rewards message and returns the signature', async () => { + const mockTimestamp = 1736660000; + const message = utf8ToBase64( + `rewards,${TEST_ADDRESS},${mockTimestamp}`, + ); + const mockSignature = '0x1234567890abcdef'; + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignRewardsMessage, + params: { + accountId: TEST_ACCOUNT_ID, + message, + }, + }; + + // Mock account lookup + mockAccountsService.findById.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: TEST_ADDRESS, + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + // Mock keypair derivation + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: TEST_PRIVATE_KEY, + } as any); + + // Mock message signing + mockTronWeb.trx.signMessageV2.mockReturnValue(mockSignature); + + const result = await clientRequestHandler.handle(request); + + expect(mockAccountsService.findById).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + ); + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: 'test-entropy', + derivationPath: [], + }); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + TEST_PRIVATE_KEY, + ); + expect(mockTronWeb.trx.signMessageV2).toHaveBeenCalledWith( + `rewards,${TEST_ADDRESS},${mockTimestamp}`, + TEST_PRIVATE_KEY, + ); + expect(result).toStrictEqual({ + signature: mockSignature, + signedMessage: message, + signatureType: 'secp256k1', + }); + }); + }); + + describe('when called with invalid parameters', () => { + it('throws error when message does not start with "rewards,"', async () => { + const invalidMessage = utf8ToBase64('invalid-message'); + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignRewardsMessage, + params: { + accountId: TEST_ACCOUNT_ID, + message: invalidMessage, + }, + }; + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Invalid method parameter(s)'); + }); + + it('throws error when account is not found', async () => { + const mockTimestamp = 1736660000; + const message = utf8ToBase64( + `rewards,${TEST_ADDRESS},${mockTimestamp}`, + ); + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignRewardsMessage, + params: { + accountId: TEST_ACCOUNT_ID, + message, + }, + }; + + mockAccountsService.findById.mockResolvedValue(null); + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Account not found'); + }); + + it('throws error when address in message does not match signing account', async () => { + const mockTimestamp = 1736660000; + // Use a different address - validation will catch invalid addresses + const differentAddress = 'invalid-address'; + const message = utf8ToBase64( + `rewards,${differentAddress},${mockTimestamp}`, + ); + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignRewardsMessage, + params: { + accountId: TEST_ACCOUNT_ID, + message, + }, + }; + + // The validation struct will catch the invalid address format + // before we can compare it to the signing account address + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Invalid method parameter(s)'); + }); + + it('throws error when message has invalid format', async () => { + const invalidMessage = utf8ToBase64('rewards,invalid'); + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignRewardsMessage, + params: { + accountId: TEST_ACCOUNT_ID, + message: invalidMessage, + }, + }; + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Invalid method parameter(s)'); + }); + + it('throws error when timestamp is invalid', async () => { + const invalidMessage = utf8ToBase64( + `rewards,${TEST_ADDRESS},invalid-timestamp`, + ); + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignRewardsMessage, + params: { + accountId: TEST_ACCOUNT_ID, + message: invalidMessage, + }, + }; + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Invalid method parameter(s)'); + }); + }); + }); +}); + +describe('ClientRequestHandler - signAndSendTransaction', () => { + let clientRequestHandler: ClientRequestHandler; + let mockAccountsService: jest.Mocked; + let mockAssetsService: jest.Mocked; + let mockSendService: jest.Mocked; + let mockFeeCalculatorService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockStakingService: jest.Mocked; + let mockConfirmationHandler: jest.Mocked; + let mockTransactionsService: jest.Mocked; + let mockTronWeb: any; + + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + const TEST_TRANSACTION_BASE64 = + 'CgK0FiII40phBu42/OZAkJyY96YzWrADCB8SqwMKMXR5cGUuZ29vZ2xlYXBpcy5jb20vcHJvdG9jb2wuVHJpZ2dlclNtYXJ0Q29udHJhY3QS9QIKFUEU0B62M0bakw7g2jDVhRrBTODEeRIVQZlGn9WqCM/oNjlc6ZPA69Vn4sFPIsQCnd+TuwAAAAAAAAAAAAAAAKYU+AO2/XgJhqQseOycf3fm3tE8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC+vCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnq6OQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF1RSWHxzeWtuZjl8MC41fGJyaWRnZXJzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACJUQnNGcUttbVJ5WWNuWUphOFlEeFk1ZDJKQVJhSGVxdUJKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcLzdlPemMw=='; + const CORRECT_OWNER_ADDRESS_HEX = + '41458437be39f3a8bfdbfee7bef93e2c5f632ceff4'; + const WRONG_OWNER_ADDRESS_HEX = '41a614f803b6fd780986a42c78ec9c7f77e6ded13c'; + const CORRECT_OWNER_ADDRESS_BASE58 = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const transactionId = 'mock-tx-id'; + + beforeEach(() => { + mockAccountsService = { + findByIdOrThrow: jest.fn(), + deriveTronKeypair: jest.fn(), + } as unknown as jest.Mocked; + + mockAssetsService = { + getAssetsByAccountId: jest.fn(), + } as unknown as jest.Mocked; + + mockSendService = {} as unknown as jest.Mocked; + + mockFeeCalculatorService = { + computeFee: jest.fn(), + } as unknown as jest.Mocked; + + mockTronWeb = { + utils: { + deserializeTx: { + deserializeTransaction: jest.fn(), + }, + transaction: { + txJsonToPb: jest.fn().mockImplementation((tx) => tx), + txPbToRawDataHex: jest.fn().mockReturnValue('1234567890abcdef'), + txPbToTxID: jest.fn().mockReturnValue(transactionId), + }, + }, + trx: { + sign: jest.fn().mockReturnValue({}), + sendRawTransaction: jest + .fn() + .mockReturnValue({ result: true, txid: transactionId }), + }, + }; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + } as unknown as jest.Mocked; + + mockSnapClient = { + scheduleBackgroundEvent: jest.fn(), + } as unknown as jest.Mocked; + + mockStakingService = {} as unknown as jest.Mocked; + + mockConfirmationHandler = {} as unknown as jest.Mocked; + + mockTransactionsService = { + save: jest.fn(), + } as unknown as jest.Mocked; + + clientRequestHandler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + sendService: mockSendService, + feeCalculatorService: mockFeeCalculatorService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + stakingService: mockStakingService, + confirmationHandler: mockConfirmationHandler, + transactionsService: mockTransactionsService, + transactionExpirationRefresherService: + createPassThroughTransactionExpirationRefresherService(), + }); + }); + + it('rejects signAndSendTransaction when owner_address does not match the signer', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignAndSendTransaction, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope, + options: { + visible: false, + type: 'TriggerSmartContract', + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: CORRECT_OWNER_ADDRESS_BASE58, + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + address: CORRECT_OWNER_ADDRESS_BASE58, + } as any); + + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({ + contract: [ + { + type: 'TriggerSmartContract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: WRONG_OWNER_ADDRESS_HEX, + }, + }, + }, + ], + }); + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow( + `Transaction owner_address (${TronWeb.address.fromHex(WRONG_OWNER_ADDRESS_HEX)}) does not match derived signer address (${CORRECT_OWNER_ADDRESS_BASE58})`, + ); + }); + + it('accepts signAndSendTransaction when owner_address matches the signer', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.SignAndSendTransaction, + params: { + accountId: TEST_ACCOUNT_ID, + transaction: TEST_TRANSACTION_BASE64, + scope, + options: { + visible: false, + type: 'TriggerSmartContract', + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: CORRECT_OWNER_ADDRESS_BASE58, + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + address: CORRECT_OWNER_ADDRESS_BASE58, + } as any); + + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({ + contract: [ + { + type: 'TriggerSmartContract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: CORRECT_OWNER_ADDRESS_HEX, + }, + }, + }, + ], + }); + + const result = await clientRequestHandler.handle(request as JsonRpcRequest); + + expect(result).toStrictEqual({ transactionId }); + }); +}); + +describe('ClientRequestHandler - onAmountInput', () => { + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + const TEST_TO_ADDRESS = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const scope = Network.Mainnet; + const nativeTokenId = Networks[scope].nativeToken.id; + + type OnAmountInputRequest = Infer; + + const mockAccount: TronKeyringAccount = { + id: TEST_ACCOUNT_ID, + address: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + type: 'tron:eoa', + options: {}, + methods: [], + scopes: [scope], + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + const createNativeAsset = ( + uiAmount: string, + rawAmount: string, + ): NativeAsset => ({ + assetType: nativeTokenId, + keyringAccountId: TEST_ACCOUNT_ID, + network: scope, + symbol: 'TRX', + decimals: 6, + rawAmount, + uiAmount, + iconUrl: Networks[scope].nativeToken.iconUrl, + }); + + const createResourceAsset = ( + assetType: ResourceAsset['assetType'], + uiAmount: string, + rawAmount: string, + ): ResourceAsset => ({ + assetType, + keyringAccountId: TEST_ACCOUNT_ID, + network: scope, + symbol: + assetType === Networks[scope].bandwidth.id + ? Networks[scope].bandwidth.symbol + : Networks[scope].energy.symbol, + decimals: 0, + rawAmount, + uiAmount, + iconUrl: + assetType === Networks[scope].bandwidth.id + ? Networks[scope].bandwidth.iconUrl + : Networks[scope].energy.iconUrl, + }); + + const createMockTransferTransaction = (): Transaction => ({ + visible: false, + txID: 'mock-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + contract: [ + { + type: 'TransferContract' as Transaction['raw_data']['contract'][number]['type'], + parameter: { + // eslint-disable-next-line @typescript-eslint/naming-convention + type_url: 'type.googleapis.com/protocol.TransferContract', + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: `41${'a'.repeat(40)}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + to_address: `41${'b'.repeat(40)}`, + amount: 1000000, + }, + }, + }, + ], + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_bytes: '0000', + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_hash: '0'.repeat(16), + expiration: Date.now() + 60000, + timestamp: Date.now(), + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'mock-hex', + }); + + it('returns valid and skips fee validation when toAddress is missing', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: nativeTokenId, + value: '10', + }, + }; + + const mockAsset = createNativeAsset('100', '100000000'); + const mockAssets: [ + NativeAsset, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + mockAsset, + mockAsset, + createResourceAsset(Networks[scope].bandwidth.id, '5000', '5000'), + createResourceAsset(Networks[scope].energy.id, '100000', '100000'), + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAssetsByAccountId.mockResolvedValue(mockAssets); + + const result = await handler.handle(request); + + expect(result).toStrictEqual({ valid: true, errors: [] }); + expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }, + ); + }); + + it('uses provided toAddress when building the transaction for fee estimation', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '2', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: nativeTokenId, + value: '10', + toAddress: TEST_TO_ADDRESS, + }, + }; + + const mockAsset = createNativeAsset('100', '100000000'); + const mockAssets: [ + NativeAsset, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + mockAsset, + mockAsset, + createResourceAsset(Networks[scope].bandwidth.id, '5000', '5000'), + createResourceAsset(Networks[scope].energy.id, '100000', '100000'), + ]; + const builtTransaction = createMockTransferTransaction(); + const mockFees: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '1', + fungible: true, + }, + }, + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAssetsByAccountId.mockResolvedValue(mockAssets); + mockSendService.buildTransaction.mockResolvedValue(builtTransaction); + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); + + const result = await handler.handle(request); + + expect(result).toStrictEqual({ valid: true, errors: [] }); + expect(mockSendService.buildTransaction).toHaveBeenCalledWith({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset: mockAsset, + amount: new BigNumber('10'), + feeLimit: FEE_LIMIT, + }); + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope, + transaction: builtTransaction, + availableEnergy: BigNumber('100000'), + availableBandwidth: BigNumber('5000'), + feeLimit: FEE_LIMIT, + }); + }, + ); + }); + + it('passes amount as BigNumber (not number) to preserve decimal precision', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '5', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: nativeTokenId, + value: '0.99', + toAddress: TEST_TO_ADDRESS, + }, + }; + + const mockAsset = createNativeAsset('100', '100000000'); + const mockAssets: [ + NativeAsset, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + mockAsset, + mockAsset, + createResourceAsset(Networks[scope].bandwidth.id, '5000', '5000'), + createResourceAsset(Networks[scope].energy.id, '100000', '100000'), + ]; + const builtTransaction = createMockTransferTransaction(); + const mockFees: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '0', + fungible: true, + }, + }, + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAssetsByAccountId.mockResolvedValue(mockAssets); + mockSendService.buildTransaction.mockResolvedValue(builtTransaction); + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); + + await handler.handle(request); + + const calledAmount = mockSendService.buildTransaction.mock.calls[0]?.[0] + ?.amount as BigNumber; + + // Must be a BigNumber, not a number — prevents IEEE 754 precision loss + expect(calledAmount).toBeInstanceOf(BigNumber); + // Must preserve exact decimal representation (0.99, not 0.98999999999999999...) + expect(calledAmount.toString()).toBe('0.99'); + }, + ); + }); + + it('returns insufficient balance when the asset balance is too low and toAddress is missing', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '3', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: nativeTokenId, + value: '10', + }, + }; + + const lowBalanceAsset = createNativeAsset('5', '5000000'); + const mockAssets: [ + NativeAsset, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + lowBalanceAsset, + lowBalanceAsset, + createResourceAsset(Networks[scope].bandwidth.id, '5000', '5000'), + createResourceAsset(Networks[scope].energy.id, '100000', '100000'), + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAssetsByAccountId.mockResolvedValue(mockAssets); + + const result = await handler.handle(request); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns insufficient balance to cover fee when toAddress is provided and fees exceed the native balance', async () => { + await withClientRequestHandler( + async ({ + handler, + mockAccountsService, + mockAssetsService, + mockSendService, + mockFeeCalculatorService, + }) => { + const request: OnAmountInputRequest = { + jsonrpc: '2.0' as const, + id: '4', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: nativeTokenId, + value: '10', + toAddress: TEST_TO_ADDRESS, + }, + }; + + const mockAsset = createNativeAsset('10', '10000000'); + const mockAssets: [ + NativeAsset, + NativeAsset, + ResourceAsset, + ResourceAsset, + ] = [ + mockAsset, + mockAsset, + createResourceAsset(Networks[scope].bandwidth.id, '0', '0'), + createResourceAsset(Networks[scope].energy.id, '0', '0'), + ]; + const builtTransaction = createMockTransferTransaction(); + const mockFees: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '1', + fungible: true, + }, + }, + ]; + + mockAccountsService.findById.mockResolvedValue(mockAccount); + mockAssetsService.getAssetsByAccountId.mockResolvedValue(mockAssets); + mockSendService.buildTransaction.mockResolvedValue(builtTransaction); + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees); + + const result = await handler.handle(request); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + }, + ); + }); + + it('tracks the error', async () => { + await withClientRequestHandler( + async ({ handler, mockAccountsService, mockSnapClient }) => { + const error = new Error('Test error'); + + mockAccountsService.findById.mockRejectedValue(error); + + await handler.handle({ + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: TEST_ACCOUNT_ID, + assetId: nativeTokenId, + value: '10', + }, + }); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); + }, + ); + }); +}); + +describe('ClientRequestHandler - computeStakeFee', () => { + let clientRequestHandler: ClientRequestHandler; + let mockAccountsService: jest.Mocked; + let mockAssetsService: jest.Mocked; + let mockSendService: jest.Mocked; + let mockFeeCalculatorService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockStakingService: jest.Mocked; + let mockConfirmationHandler: jest.Mocked; + let mockTransactionsService: jest.Mocked; + let mockTronWeb: any; + + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + + beforeEach(() => { + mockAccountsService = { + findByIdOrThrow: jest.fn(), + deriveTronKeypair: jest.fn(), + } as unknown as jest.Mocked; + + mockAssetsService = { + getAssetByAccountId: jest.fn(), + getAssetsByAccountId: jest.fn(), + } as unknown as jest.Mocked; + + mockSendService = {} as unknown as jest.Mocked; + + mockFeeCalculatorService = { + computeFee: jest.fn(), + } as unknown as jest.Mocked; + + mockTronWeb = { + trx: { + sign: jest.fn(), + }, + transactionBuilder: { + freezeBalanceV2: jest.fn(), + }, + }; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + } as unknown as jest.Mocked; + + mockSnapClient = {} as unknown as jest.Mocked; + mockStakingService = {} as unknown as jest.Mocked; + mockConfirmationHandler = {} as unknown as jest.Mocked; + mockTransactionsService = { + save: jest.fn(), + } as unknown as jest.Mocked; + clientRequestHandler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + sendService: mockSendService, + feeCalculatorService: mockFeeCalculatorService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + stakingService: mockStakingService, + confirmationHandler: mockConfirmationHandler, + transactionsService: mockTransactionsService, + transactionExpirationRefresherService: + createPassThroughTransactionExpirationRefresherService(), + }); + }); + + it('computes fee breakdown for TRX staking on mainnet', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.ComputeStakeFee, + params: { + fromAccountId: TEST_ACCOUNT_ID, + value: '10', + options: { + purpose: 'ENERGY' as const, + }, + }, + }; + + const scope = Network.Mainnet; + + // Mock account lookup + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + // Mock keypair derivation + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + privateKeyHex: 'test-private-key', + } as any); + + const builtTransaction = { + txID: 'stake-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + contract: [], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'stake-hex', + }; + + mockTronWeb.transactionBuilder.freezeBalanceV2.mockResolvedValue( + builtTransaction, + ); + + const signedTransaction = { + ...builtTransaction, + signature: ['stake-signature'], + }; + mockTronWeb.trx.sign.mockResolvedValue(signedTransaction); + + // Native TRX asset for mainnet + const nativeAssetId = Networks[scope].nativeToken.id; + + // Mock native balance and resources + (mockAssetsService.getAssetByAccountId as jest.Mock).mockResolvedValue({ + uiAmount: '100', + }); + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { rawAmount: '5000' }, // Bandwidth + { rawAmount: '100000' }, // Energy + ] as any); + + const feeResult = [ + { + type: FeeType.Base, + asset: { + unit: Networks[scope].energy.symbol, + type: Networks[scope].energy.id, + amount: '65000', + fungible: true as const, + }, + }, + ]; + mockFeeCalculatorService.computeFee.mockResolvedValue(feeResult); + + const result = await clientRequestHandler.handle(request as JsonRpcRequest); + + expect(mockAccountsService.findByIdOrThrow).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + ); + // deriveTronKeypair is NOT called - no private key needed for fee computation + expect(mockAccountsService.deriveTronKeypair).not.toHaveBeenCalled(); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith(scope); + expect(mockTronWeb.transactionBuilder.freezeBalanceV2).toHaveBeenCalledWith( + Number(trxToSun(10)), + 'ENERGY', + 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + ); + expect(mockAssetsService.getAssetByAccountId).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + nativeAssetId, + ); + expect(mockAssetsService.getAssetsByAccountId).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + [Networks[scope].bandwidth.id, Networks[scope].energy.id], + ); + // computeFee receives unsigned transaction (no signature field) + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope, + transaction: builtTransaction, + availableEnergy: BigNumber('100000'), + availableBandwidth: BigNumber('5000'), + }); + expect(result).toStrictEqual(feeResult); + }); + + it('returns insufficient balance error when staking more than balance', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '2', + method: ClientRequestMethod.ComputeStakeFee, + params: { + fromAccountId: TEST_ACCOUNT_ID, + value: '10', + options: { + purpose: 'BANDWIDTH' as const, + }, + }, + }; + + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: [], + } as any); + + // Account has only 5 TRX + (mockAssetsService.getAssetByAccountId as jest.Mock).mockResolvedValue({ + uiAmount: '5', + }); + + const result = (await clientRequestHandler.handle( + request as JsonRpcRequest, + )) as any; + + expect(result).toStrictEqual({ + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }); + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }); +}); + +describe('ClientRequestHandler - confirmSend validation', () => { + let clientRequestHandler: ClientRequestHandler; + let mockAccountsService: jest.Mocked; + let mockAssetsService: jest.Mocked; + let mockSendService: jest.Mocked; + let mockFeeCalculatorService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockStakingService: jest.Mocked; + let mockConfirmationHandler: jest.Mocked; + let mockTransactionsService: jest.Mocked; + let mockTransactionExpirationRefresherService: jest.Mocked< + Pick + >; + + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + const TEST_TO_ADDRESS = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const scope = Network.Mainnet; + + beforeEach(() => { + mockAccountsService = { + findById: jest.fn(), + findByIdOrThrow: jest.fn(), + deriveTronKeypair: jest.fn(), + } as unknown as jest.Mocked; + + mockAssetsService = { + getAssetByAccountId: jest.fn(), + getAssetsByAccountId: jest.fn(), + } as unknown as jest.Mocked; + + mockSendService = { + validateSend: jest.fn(), + buildTransaction: jest.fn(), + signAndSendTransaction: jest.fn(), + } as unknown as jest.Mocked; + + mockFeeCalculatorService = { + computeFee: jest.fn(), + } as unknown as jest.Mocked; + + mockTronWebFactory = { + createClient: jest.fn(), + } as unknown as jest.Mocked; + + mockSnapClient = {} as unknown as jest.Mocked; + mockStakingService = {} as unknown as jest.Mocked; + mockConfirmationHandler = { + confirmTransactionRequest: jest.fn(), + } as unknown as jest.Mocked; + mockTransactionsService = { + save: jest.fn(), + } as unknown as jest.Mocked; + mockTransactionExpirationRefresherService = { + ensureFreshRawData: jest.fn(async ({ rawData }) => rawData), + } as unknown as jest.Mocked< + Pick + >; + + clientRequestHandler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + sendService: mockSendService, + feeCalculatorService: mockFeeCalculatorService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + stakingService: mockStakingService, + confirmationHandler: mockConfirmationHandler, + transactionsService: mockTransactionsService, + transactionExpirationRefresherService: + mockTransactionExpirationRefresherService as unknown as TransactionExpirationRefresherService, + }); + }); + + it('returns InsufficientBalance when validateSend returns InsufficientBalance', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + amount: '10', + assetId: Networks[scope].nativeToken.id, + }, + }; + + // Mock account found + mockAccountsService.findById.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + entropySource: 'test-entropy', + derivationPath: [], + type: 'tron:basic', + } as any); + + // Mock asset found + const mockAsset = { + assetType: Networks[scope].nativeToken.id, + symbol: 'TRX', + decimals: 6, + uiAmount: '100', + rawAmount: '100000000', + }; + (mockAssetsService.getAssetByAccountId as jest.Mock).mockResolvedValue( + mockAsset, + ); + + // validateSend returns insufficient balance + mockSendService.validateSend.mockResolvedValue({ + valid: false, + errorCode: 'InsufficientBalance' as any, + }); + + const result = (await clientRequestHandler.handle( + request as JsonRpcRequest, + )) as any; + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + + expect(mockSendService.validateSend).toHaveBeenCalledWith({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset: mockAsset, + amount: BigNumber('10'), + feeLimit: FEE_LIMIT, + }); + + // Should not proceed to build transaction or confirmation + expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); + expect( + mockConfirmationHandler.confirmTransactionRequest, + ).not.toHaveBeenCalled(); + }); + + it('returns InsufficientBalanceToCoverFee when validateSend returns InsufficientBalanceToCoverFee', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + amount: '99', + assetId: Networks[scope].nativeToken.id, + }, + }; + + mockAccountsService.findById.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + entropySource: 'test-entropy', + derivationPath: [], + type: 'tron:basic', + } as any); + + const mockAsset = { + assetType: Networks[scope].nativeToken.id, + symbol: 'TRX', + decimals: 6, + uiAmount: '100', + rawAmount: '100000000', + }; + (mockAssetsService.getAssetByAccountId as jest.Mock).mockResolvedValue( + mockAsset, + ); + + // validateSend returns insufficient balance to cover fee + mockSendService.validateSend.mockResolvedValue({ + valid: false, + errorCode: 'InsufficientBalanceToCoverFee' as any, + }); + + const result = (await clientRequestHandler.handle( + request as JsonRpcRequest, + )) as any; + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + + expect(mockSendService.buildTransaction).not.toHaveBeenCalled(); + expect( + mockConfirmationHandler.confirmTransactionRequest, + ).not.toHaveBeenCalled(); + }); + + it('refreshes transaction raw data before send confirmation when validateSend returns valid', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + amount: '10', + assetId: Networks[scope].nativeToken.id, + }, + }; + + const mockAccount = { + id: TEST_ACCOUNT_ID, + address: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + entropySource: 'test-entropy', + derivationPath: [], + type: 'tron:basic', + }; + mockAccountsService.findById.mockResolvedValue(mockAccount as any); + + const mockAsset = { + assetType: Networks[scope].nativeToken.id, + symbol: 'TRX', + decimals: 6, + uiAmount: '100', + rawAmount: '100000000', + }; + (mockAssetsService.getAssetByAccountId as jest.Mock).mockResolvedValue( + mockAsset, + ); + + // validateSend returns valid. + mockSendService.validateSend.mockResolvedValue({ valid: true }); + + // Mock the rest of the flow. + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { rawAmount: '1000' }, // Bandwidth + { rawAmount: '50000' }, // Energy + ] as any); + + const mockTransaction = { + txID: 'test-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: {}, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'test-hex', + }; + mockSendService.buildTransaction.mockResolvedValue(mockTransaction as any); + const freshTransactionRawData = { expiration: 1_700_000_060_000 }; + mockTransactionExpirationRefresherService.ensureFreshRawData.mockResolvedValue( + freshTransactionRawData as any, + ); + + const mockFees = [ + { + type: 'base' as const, + asset: { + unit: 'TRX', + type: Networks[scope].nativeToken.id, + amount: '0', + fungible: true as const, + }, + }, + ]; + mockFeeCalculatorService.computeFee.mockResolvedValue(mockFees as any); + + // User confirms. + mockConfirmationHandler.confirmTransactionRequest.mockResolvedValue(true); + + // Transaction sent successfully. + mockSendService.signAndSendTransaction.mockResolvedValue({ + result: true, + txid: 'broadcast-tx-id', + } as any); + + const result = await clientRequestHandler.handle(request as JsonRpcRequest); + + // Verify the full send flow. + expect(mockSendService.validateSend).toHaveBeenCalledWith({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset: mockAsset, + amount: BigNumber('10'), + feeLimit: FEE_LIMIT, + }); + expect(mockSendService.buildTransaction).toHaveBeenCalledWith({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset: mockAsset, + amount: BigNumber('10'), + feeLimit: FEE_LIMIT, + }); + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope, + transaction: mockTransaction, + availableEnergy: BigNumber('50000'), + availableBandwidth: BigNumber('1000'), + feeLimit: FEE_LIMIT, + }); + expect( + mockTransactionExpirationRefresherService.ensureFreshRawData, + ).toHaveBeenCalledWith({ + scope, + rawData: mockTransaction.raw_data, + }); + expect( + mockConfirmationHandler.confirmTransactionRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + transactionRawData: freshTransactionRawData, + }), + ); + expect(mockSendService.signAndSendTransaction).toHaveBeenCalled(); + + // Verify the handler returns the submitted transaction. + expect(result).toMatchObject({ + transactionId: 'broadcast-tx-id', + status: 'submitted', + }); + + // buildTransaction must receive a BigNumber to preserve decimal precision + const calledAmount = mockSendService.buildTransaction.mock.calls[0]?.[0] + ?.amount as BigNumber; + expect(calledAmount).toBeInstanceOf(BigNumber); + expect(calledAmount.toString()).toBe('10'); + }); + + it('returns Invalid error when account is not found', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + amount: '10', + assetId: Networks[scope].nativeToken.id, + }, + }; + + // Account not found + mockAccountsService.findById.mockResolvedValue(null); + + const result = (await clientRequestHandler.handle( + request as JsonRpcRequest, + )) as any; + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + + expect(mockSendService.validateSend).not.toHaveBeenCalled(); + }); + + it('returns InsufficientBalance when asset is not found', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + amount: '10', + assetId: Networks[scope].nativeToken.id, + }, + }; + + mockAccountsService.findById.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + entropySource: 'test-entropy', + derivationPath: [], + type: 'tron:basic', + } as any); + + // Asset not found + (mockAssetsService.getAssetByAccountId as jest.Mock).mockResolvedValue( + null, + ); + + const result = (await clientRequestHandler.handle( + request as JsonRpcRequest, + )) as any; + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + + expect(mockSendService.validateSend).not.toHaveBeenCalled(); + }); +}); + +describe('ClientRequestHandler - claimUnstakedTrx', () => { + let clientRequestHandler: ClientRequestHandler; + let mockAccountsService: jest.Mocked; + let mockAssetsService: jest.Mocked; + let mockSendService: jest.Mocked; + let mockFeeCalculatorService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockStakingService: jest.Mocked; + let mockConfirmationHandler: jest.Mocked; + let mockTransactionsService: jest.Mocked; + + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + + const mockAccount = { + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + }; + + beforeEach(() => { + mockAccountsService = { + findByIdOrThrow: jest.fn().mockResolvedValue(mockAccount), + } as unknown as jest.Mocked; + + mockAssetsService = {} as unknown as jest.Mocked; + mockSendService = {} as unknown as jest.Mocked; + mockFeeCalculatorService = + {} as unknown as jest.Mocked; + mockTronWebFactory = { + createClient: jest.fn(), + } as unknown as jest.Mocked; + mockSnapClient = {} as unknown as jest.Mocked; + mockStakingService = { + claimUnstakedTrx: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + mockConfirmationHandler = { + confirmClaimUnstakedTrx: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked; + mockTransactionsService = { + save: jest.fn(), + } as unknown as jest.Mocked; + + clientRequestHandler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + sendService: mockSendService, + feeCalculatorService: mockFeeCalculatorService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + stakingService: mockStakingService, + confirmationHandler: mockConfirmationHandler, + transactionsService: mockTransactionsService, + transactionExpirationRefresherService: + createPassThroughTransactionExpirationRefresherService(), + }); + }); + + it('claims unstaked TRX successfully when user confirms', async () => { + mockConfirmationHandler.confirmClaimUnstakedTrx.mockResolvedValue(true); + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: 'claimUnstakedTrx', + params: { + fromAccountId: TEST_ACCOUNT_ID, + assetId: Networks[Network.Mainnet].nativeToken.id, + }, + }; + + const result = await clientRequestHandler.handle(request as JsonRpcRequest); + + expect(mockAccountsService.findByIdOrThrow).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + ); + expect( + mockConfirmationHandler.confirmClaimUnstakedTrx, + ).toHaveBeenCalledWith({ + account: mockAccount, + scope: Network.Mainnet, + }); + expect(mockStakingService.claimUnstakedTrx).toHaveBeenCalledWith({ + account: mockAccount, + scope: Network.Mainnet, + }); + expect(result).toStrictEqual({ valid: true, errors: [] }); + }); + + it('throws when user rejects the confirmation', async () => { + mockConfirmationHandler.confirmClaimUnstakedTrx.mockResolvedValue(false); + + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: 'claimUnstakedTrx', + params: { + fromAccountId: TEST_ACCOUNT_ID, + assetId: Networks[Network.Mainnet].nativeToken.id, + }, + }; + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('User rejected the request.'); + + expect( + mockConfirmationHandler.confirmClaimUnstakedTrx, + ).toHaveBeenCalledWith({ + account: mockAccount, + scope: Network.Mainnet, + }); + expect(mockStakingService.claimUnstakedTrx).not.toHaveBeenCalled(); + }); + + it('throws InvalidParamsError for invalid params', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: 'claimUnstakedTrx', + params: { + fromAccountId: 'not-a-uuid', + assetId: 'invalid-asset', + }, + }; + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Invalid method parameter(s)'); + }); +}); + +describe('ClientRequestHandler - claimTrxStakingRewards', () => { + let clientRequestHandler: ClientRequestHandler; + let mockAccountsService: jest.Mocked; + let mockAssetsService: jest.Mocked; + let mockSendService: jest.Mocked; + let mockFeeCalculatorService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockStakingService: jest.Mocked; + let mockConfirmationHandler: jest.Mocked; + let mockTransactionsService: jest.Mocked; + + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + + const mockAccount = { + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + }; + + beforeEach(() => { + mockAccountsService = { + findByIdOrThrow: jest.fn().mockResolvedValue(mockAccount), + } as unknown as jest.Mocked; + + mockAssetsService = {} as unknown as jest.Mocked; + mockSendService = {} as unknown as jest.Mocked; + mockFeeCalculatorService = + {} as unknown as jest.Mocked; + mockTronWebFactory = { + createClient: jest.fn(), + } as unknown as jest.Mocked; + mockSnapClient = {} as unknown as jest.Mocked; + mockStakingService = { + claimTrxStakingRewards: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + mockConfirmationHandler = {} as unknown as jest.Mocked; + mockTransactionsService = { + save: jest.fn(), + } as unknown as jest.Mocked; + + clientRequestHandler = new ClientRequestHandler({ + logger: mockLogger, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + sendService: mockSendService, + feeCalculatorService: mockFeeCalculatorService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + stakingService: mockStakingService, + confirmationHandler: mockConfirmationHandler, + transactionsService: mockTransactionsService, + transactionExpirationRefresherService: + createPassThroughTransactionExpirationRefresherService(), + }); + }); + + it('claims staking rewards successfully', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: 'claimTrxStakingRewards', + params: { + fromAccountId: TEST_ACCOUNT_ID, + assetId: Networks[Network.Mainnet].nativeToken.id, + }, + }; + + const result = await clientRequestHandler.handle(request as JsonRpcRequest); + + expect(mockAccountsService.findByIdOrThrow).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + ); + expect(mockStakingService.claimTrxStakingRewards).toHaveBeenCalledWith({ + account: mockAccount, + scope: Network.Mainnet, + }); + expect(result).toStrictEqual({ valid: true, errors: [] }); + }); + + it('throws InvalidParamsError for invalid params', async () => { + const request = { + jsonrpc: '2.0' as const, + id: '1', + method: 'claimTrxStakingRewards', + params: { + fromAccountId: 'not-a-uuid', + assetId: 'invalid-asset', + }, + }; + + await expect( + clientRequestHandler.handle(request as JsonRpcRequest), + ).rejects.toThrow('Invalid method parameter(s)'); + }); +}); + +describe('ClientRequestHandler - onAddressInput', () => { + it('tracks the error', async () => { + await withClientRequestHandler(async ({ handler, mockSnapClient }) => { + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: ClientRequestMethod.OnAddressInput, + params: { + value: 'invalid-address', + }, + }); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts new file mode 100644 index 00000000..47b15151 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -0,0 +1,1145 @@ +import { TransactionStatus } from '@metamask/keyring-api'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { + InvalidParamsError, + MethodNotFoundError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import { + bytesToHex, + hexToBytes, + parseCaipAssetType, + sha256, +} from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import type { TronWeb, Types } from 'tronweb'; + +import { ClientRequestMethod, SendErrorCodes } from './types'; +import { + ClaimTrxStakingRewardsRequestStruct, + ClaimUnstakedTrxRequestStruct, + ComputeFeeRequestStruct, + ComputeFeeResponseStruct, + ComputeStakeFeeRequestStruct, + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, + OnConfirmSendRequestStruct, + OnConfirmStakeRequestStruct, + OnConfirmUnstakeRequestStruct, + OnStakeAmountInputRequestStruct, + OnUnstakeAmountInputRequestStruct, + parseRewardsMessage, + SignAndSendTransactionRequestStruct, + SignRewardsMessageRequestStruct, +} from './validation'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import { FEE_LIMIT, Network, Networks, ZERO } from '../../constants'; +import type { AccountsService } from '../../services/accounts/AccountsService'; +import type { AssetsService } from '../../services/assets/AssetsService'; +import type { + NativeCaipAssetType, + StakedCaipAssetType, +} from '../../services/assets/types'; +import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; +import type { FeeCalculatorService } from '../../services/send/FeeCalculatorService'; +import type { SendService } from '../../services/send/SendService'; +import type { StakingService } from '../../services/staking/StakingService'; +import type { TransactionExpirationRefresherService } from '../../services/transaction-expiration-refresher/TransactionExpirationRefresherService'; +import { TransactionMapper } from '../../services/transactions/TransactionsMapper'; +import type { TransactionsService } from '../../services/transactions/TransactionsService'; +import { assertOrThrow } from '../../utils/assertOrThrow'; +import { trxToSun } from '../../utils/conversion'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import { + assertTransactionSignerConsistency, + assertTransactionStructure, +} from '../../validation/transaction'; +import { BackgroundEventMethod } from '../cronjob'; + +type TransactionRawData = Types.Transaction['raw_data'] & { + // eslint-disable-next-line @typescript-eslint/naming-convention + fee_limit?: number; +}; + +export class ClientRequestHandler { + readonly #logger: ILogger; + + readonly #accountsService: AccountsService; + + readonly #assetsService: AssetsService; + + readonly #sendService: SendService; + + readonly #tronWebFactory: TronWebFactory; + + readonly #feeCalculatorService: FeeCalculatorService; + + readonly #snapClient: SnapClient; + + readonly #stakingService: StakingService; + + readonly #confirmationHandler: ConfirmationHandler; + + readonly #transactionsService: TransactionsService; + + readonly #transactionExpirationRefresherService: TransactionExpirationRefresherService; + + constructor({ + logger, + accountsService, + assetsService, + sendService, + feeCalculatorService, + tronWebFactory, + snapClient, + stakingService, + confirmationHandler, + transactionsService, + transactionExpirationRefresherService, + }: { + logger: ILogger; + accountsService: AccountsService; + assetsService: AssetsService; + sendService: SendService; + feeCalculatorService: FeeCalculatorService; + tronWebFactory: TronWebFactory; + snapClient: SnapClient; + stakingService: StakingService; + confirmationHandler: ConfirmationHandler; + transactionsService: TransactionsService; + transactionExpirationRefresherService: TransactionExpirationRefresherService; + }) { + this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); + this.#accountsService = accountsService; + this.#assetsService = assetsService; + this.#sendService = sendService; + this.#feeCalculatorService = feeCalculatorService; + this.#tronWebFactory = tronWebFactory; + this.#snapClient = snapClient; + this.#stakingService = stakingService; + this.#confirmationHandler = confirmationHandler; + this.#transactionsService = transactionsService; + this.#transactionExpirationRefresherService = + transactionExpirationRefresherService; + } + + /** + * Handles JSON-RPC requests originating exclusively from the client - as defined in [SIP-31](https://github.com/MetaMask/SIPs/blob/main/SIPS/sip-31.md) - + * by routing them to the appropriate use case, based on the method. Some methods need to be implemented + * as part of the [Unified Non-EVM Send](https://www.notion.so/metamask-consensys/Unified-Non-EVM-Send-248f86d67d6880278445f9ad75478471) specification. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {MethodNotFoundError} If the method is not found. + * @throws {InvalidParamsError} If the params are invalid. + */ + async handle(request: JsonRpcRequest): Promise { + this.#logger.log('Handling client request', request); + + const { method } = request; + + switch (method as ClientRequestMethod) { + /** + * Wallet Standard + */ + case ClientRequestMethod.SignAndSendTransaction: + return this.#handleSignAndSendTransaction(request); + /** + * Unified non-EVM Send + */ + case ClientRequestMethod.OnAddressInput: + return this.#handleOnAddressInput(request); + case ClientRequestMethod.OnAmountInput: + return this.#handleOnAmountInput(request); + case ClientRequestMethod.ConfirmSend: + return this.#handleConfirmSend(request); + case ClientRequestMethod.ComputeFee: + return this.#handleComputeFee(request); + case ClientRequestMethod.ComputeStakeFee: + return this.#handleComputeStakeFee(request); + /** + * Staking + */ + case ClientRequestMethod.OnStakeAmountInput: + return this.#handleOnStakeAmountInput(request); + case ClientRequestMethod.ConfirmStake: + return this.#handleConfirmStake(request); + case ClientRequestMethod.OnUnstakeAmountInput: + return this.#handleOnUnstakeAmountInput(request); + case ClientRequestMethod.ConfirmUnstake: + return this.#handleConfirmUnstake(request); + case ClientRequestMethod.ClaimUnstakedTrx: + return this.#handleClaimUnstakedTrx(request); + case ClientRequestMethod.ClaimTrxStakingRewards: + return this.#handleClaimTrxStakingRewards(request); + /** + * Sign Rewards Message + */ + case ClientRequestMethod.SignRewardsMessage: + return this.#handleSignRewardsMessage(request); + default: + throw new MethodNotFoundError() as Error; + } + } + + /** + * Handles the signing and sending of a transaction. + * + * CRITICAL SECURITY REQUIREMENT: + * This method does NOT request user confirmation. The caller is responsible + * for obtaining explicit user consent before invoking this method. + * + * The caller MUST: + * - Display transaction details (recipient, amount, fees) to the user + * - Obtain explicit user approval before calling this method + * - Validate transaction authenticity and integrity + * + * Failure to implement caller-side consent will result in transactions being + * signed and broadcast without user knowledge, creating a critical security + * vulnerability. + * + * @param request - The JSON-RPC request containing transaction details. + * @returns The transaction result with hash and status. + */ + async #handleSignAndSendTransaction(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + SignAndSendTransactionRequestStruct, + new InvalidParamsError(), + ); + + /** + * Transaction here is in base64 format. + */ + const { + transaction: transactionBase64, + accountId, + scope, + options: { type }, + } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(accountId); + + const { privateKeyHex, address: signerAddress } = + await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + /** + * We need to rebuild the transaction due to some extra fields + */ + // eslint-disable-next-line no-restricted-globals + let rawDataHex = Buffer.from(transactionBase64, 'base64').toString('hex'); + const rawData = tronWeb.utils.deserializeTx.deserializeTransaction( + type, + rawDataHex, + ) as TransactionRawData; + rawDataHex = this.#setRawDataFeeLimit(tronWeb, rawData); + + assertTransactionStructure(rawData); + assertTransactionSignerConsistency(rawData, signerAddress); + + const txID = bytesToHex(await sha256(hexToBytes(rawDataHex))).slice(2); + const transaction = { + /** + * Deserialized transaction always hexadecimal addresses + */ + visible: false, + txID, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: rawData, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: rawDataHex, + }; + + const freshTransaction = + await this.#transactionExpirationRefresherService.ensureFreshMetadata({ + scope, + transaction, + }); + + const signedTx = await tronWeb.trx.sign(freshTransaction); + const result = await tronWeb.trx.sendRawTransaction(signedTx); + + if (!result.result) { + throw new Error(`Failed to send transaction: ${result.message}`); + } + + // Immediately create and save a minimal pending transaction + // This shows the transaction to the user right away + const pendingTransaction = TransactionMapper.createPendingTransaction({ + txId: result.txid, + account, + scope, + }); + + await this.#transactionsService.save(pendingTransaction); + + /** + * Track transaction after a transaction + */ + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.TrackTransaction, + params: { + txId: result.txid, + scope, + accountIds: [accountId], + attempt: 0, + }, + duration: 'PT1S', + }); + + return { + transactionId: result.txid, + }; + } + + /** + * Handles the input of an address. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleOnAddressInput(request: JsonRpcRequest): Promise { + try { + assert(request, OnAddressInputRequestStruct); + return { + valid: true, + errors: [], + }; + } catch (error) { + await this.#snapClient.trackError(error as Error); + return { + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }; + } + } + + /** + * Handles amount input validation and balance checking. + * - Asset must exist in the account + * - Asset amount must not be greater than the account balance + * - If native TRX + full amount, we need bandwidth + * - + * + * @param request - The JSON-RPC request containing amount and asset details. + * @returns Validation result with balance and sufficiency status. + */ + async #handleOnAmountInput(request: JsonRpcRequest): Promise { + try { + assert(request, OnAmountInputRequestStruct); + + const { accountId, assetId, value, toAddress } = request.params; + + const account = await this.#accountsService.findById(accountId); + + /** + * Check if the account we want to send from exists... + */ + if (!account) { + return { + valid: false, + errors: [{ code: SendErrorCodes.Required }], + }; + } + + /** + * Check if we have enough of the asset we want to send... + */ + const { chainId } = parseCaipAssetType(assetId); + const scope = chainId as Network; + + const [asset, nativeTokenAsset, bandwidthAsset, energyAsset] = + await this.#assetsService.getAssetsByAccountId(accountId, [ + assetId, + Networks[scope].nativeToken.id, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); + + const valueBN = new BigNumber(value); + const assetToSendBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const nativeTokenBalance = nativeTokenAsset + ? new BigNumber(nativeTokenAsset.uiAmount) + : ZERO; + const bandwidthBalance = bandwidthAsset + ? new BigNumber(bandwidthAsset.uiAmount) + : ZERO; + const energyBalance = energyAsset + ? new BigNumber(energyAsset.uiAmount) + : ZERO; + + if (!asset || valueBN.isGreaterThan(assetToSendBalance)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + if (!toAddress) { + return { + valid: true, + errors: [], + }; + } + + /** + * Estimate the fees + */ + const sendTransaction = await this.#sendService.buildTransaction({ + fromAccountId: accountId, + toAddress, + asset, + amount: valueBN, + feeLimit: FEE_LIMIT, + }); + const fees = await this.#feeCalculatorService.computeFee({ + scope, + transaction: sendTransaction, + availableEnergy: energyBalance, + availableBandwidth: bandwidthBalance, + feeLimit: FEE_LIMIT, + }); + + /** + * The fee calculation already takes into account the energy and bandwidth consumption, + * so we only need to make sure we have enough TRX to cover overages. + */ + const nativeTokenId = Networks[scope].nativeToken.id; + const trxFee = new BigNumber( + fees.find((fee) => fee.asset.type === nativeTokenId)?.asset.amount ?? + '0', + ); + + /** + * Don't forget that we can also be sending TRX so we must add the fees to the amount that will be + * sent. + */ + const totalTrxToSpend = + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + assetId === nativeTokenId ? valueBN.plus(trxFee) : trxFee; + + if (totalTrxToSpend.isGreaterThan(nativeTokenBalance)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }; + } + + return { + valid: true, + errors: [], + }; + } catch (error) { + await this.#snapClient.trackError(error as Error); + this.#logger.error('Error in #handleOnAmountInput:', error); + return { + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }; + } + } + + /** + * Handles the confirmation and sending of a transaction. + * + * @param request - The JSON-RPC request containing transaction details. + * @returns The transaction result with hash and status. + */ + async #handleConfirmSend(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + OnConfirmSendRequestStruct, + new InvalidParamsError(), + ); + + const { fromAccountId, toAddress, amount, assetId } = request.params; + + const account = await this.#accountsService.findById(fromAccountId); + + if (!account) { + return { + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }; + } + + const asset = await this.#assetsService.getAssetByAccountId( + fromAccountId, + assetId, + ); + + if (!asset) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + const { chainId } = parseCaipAssetType(assetId); + const scope = chainId as Network; + + const amountBN = new BigNumber(amount); + + /** + * Validate that the user has enough funds to cover both the amount + * and all associated fees (including account activation if applicable). + * This prevents users from confirming sends that we know will fail. + */ + const validation = await this.#sendService.validateSend({ + scope, + fromAccountId, + toAddress, + asset, + amount: amountBN, + feeLimit: FEE_LIMIT, + }); + + if (!validation.valid) { + return { + valid: false, + errors: [ + { code: validation.errorCode ?? SendErrorCodes.InsufficientBalance }, + ], + }; + } + + const [[bandwidthAsset, energyAsset], transaction] = await Promise.all([ + /** + * Get available Energy and Bandwidth from account assets. + */ + this.#assetsService.getAssetsByAccountId(fromAccountId, [ + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]), + /** + * Build the unsigned transaction. + * Fee estimation uses a constant overhead for the signature (134 bytes). + * Signing happens after user confirmation in sendTransaction(). + */ + this.#sendService.buildTransaction({ + fromAccountId, + toAddress, + asset, + amount: amountBN, + feeLimit: FEE_LIMIT, + }), + ]); + + const availableEnergy = energyAsset + ? new BigNumber(energyAsset.rawAmount) + : ZERO; + const availableBandwidth = bandwidthAsset + ? new BigNumber(bandwidthAsset.rawAmount) + : ZERO; + + const fees = await this.#feeCalculatorService.computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + feeLimit: FEE_LIMIT, + }); + + const freshTransactionRawData = + await this.#transactionExpirationRefresherService.ensureFreshRawData({ + scope, + rawData: transaction.raw_data, + }); + + /** + * Show the confirmation UI. + * Origin is 'MetaMask' because client requests come from MetaMask's own unified send flow. + */ + const confirmed = await this.#confirmationHandler.confirmTransactionRequest( + { + scope, + fromAddress: account.address, + toAddress, + amount, + fees, + asset, + accountType: account.type, + origin: 'MetaMask', + transactionRawData: freshTransactionRawData, + }, + ); + + if (!confirmed) { + throw new UserRejectedRequestError() as unknown as Error; + } + + /** + * Send the built transaction + */ + const result = await this.#sendService.signAndSendTransaction({ + scope, + fromAccountId, + transaction, + }); + + // Immediately create and save a detailed pending Send transaction + // This shows the transaction to the user right away with all details + const pendingTransaction = TransactionMapper.createPendingSendTransaction({ + txId: result.txid, + account, + scope, + toAddress, + amount, + assetType: assetId, + assetSymbol: asset.symbol, + }); + + await this.#transactionsService.save(pendingTransaction); + + this.#logger.log( + `Created pending Send transaction ${result.txid} for account ${account.id}`, + ); + + return { + transactionId: result.txid, + status: TransactionStatus.Submitted, + }; + } + + /** + * Handles the computation of a fee for a TRON transaction. + * Returns used energy, used bandwidth, and the additional TRX cost breakdown for overages. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request with the detailed fee breakdown. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleComputeFee(request: JsonRpcRequest): Promise { + assertOrThrow(request, ComputeFeeRequestStruct, new InvalidParamsError()); + + const { + params: { + scope, + transaction: transactionBase64, + accountId, + options: { type, feeLimit }, + }, + } = request; + + /** + * Recreate the transaction object from base64-encoded raw data. + * No signing needed - fee calculation uses constant overhead for signature. + */ + await this.#accountsService.findByIdOrThrow(accountId); + + const tronWeb = this.#tronWebFactory.createClient(scope); + + // eslint-disable-next-line no-restricted-globals + let rawDataHex = Buffer.from(transactionBase64, 'base64').toString('hex'); + const rawData = tronWeb.utils.deserializeTx.deserializeTransaction( + type, + rawDataHex, + ) as TransactionRawData; + rawDataHex = this.#setRawDataFeeLimit(tronWeb, rawData, feeLimit); + + assertTransactionStructure(rawData); + + const txID = bytesToHex(await sha256(hexToBytes(rawDataHex))).slice(2); + const transaction = { + visible: false, + txID, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: rawData, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: rawDataHex, + }; + + /** + * Get available Energy and Bandwidth from account assets. + */ + const [bandwidthAsset, energyAsset] = + await this.#assetsService.getAssetsByAccountId(accountId, [ + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); + + const availableEnergy = energyAsset + ? new BigNumber(energyAsset.rawAmount) + : ZERO; + const availableBandwidth = bandwidthAsset + ? new BigNumber(bandwidthAsset.rawAmount) + : ZERO; + + /** + * Calculate complete fee breakdown using the service. + * Uses constant overhead (134 bytes) for unsigned transactions. + */ + const result = await this.#feeCalculatorService.computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + feeLimit: rawData.fee_limit, + }); + + assert(result, ComputeFeeResponseStruct); + + return result; + } + + /** + * Computes a fee preview for a staking transaction. + * It builds and signs a freezeBalanceV2 transaction on the fly and uses the + * FeeCalculatorService to estimate resource usage and TRX cost. + * + * @param request - The JSON-RPC request containing staking details. + * @returns The detailed fee breakdown for the staking transaction. + * @throws {InvalidParamsError} If the params are invalid. + */ + async #handleComputeStakeFee(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + ComputeStakeFeeRequestStruct, + new InvalidParamsError(), + ); + + const { + fromAccountId, + value, + options: { purpose }, + } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const scope = Network.Mainnet; + + const asset = await this.#assetsService.getAssetByAccountId( + fromAccountId, + Networks[scope].nativeToken.id, + ); + + const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const requestBalance = BigNumber(value); + + /** + * Check if account has enough of the asset for staking. + */ + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [SendErrorCodes.InsufficientBalance], + }; + } + + const tronWeb = this.#tronWebFactory.createClient(scope); + + const amountInSun = Number(trxToSun(requestBalance)); + const transaction = await tronWeb.transactionBuilder.freezeBalanceV2( + amountInSun, + purpose, + account.address, + ); + + /** + * Get available Energy and Bandwidth from account assets. + */ + const [bandwidthAsset, energyAsset] = + await this.#assetsService.getAssetsByAccountId(fromAccountId, [ + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); + + const availableEnergy = energyAsset + ? BigNumber(energyAsset.rawAmount) + : ZERO; + const availableBandwidth = bandwidthAsset + ? BigNumber(bandwidthAsset.rawAmount) + : ZERO; + + /** + * Calculate complete fee breakdown using the service. + * Uses constant overhead (134 bytes) for unsigned transactions. + */ + const result = await this.#feeCalculatorService.computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + }); + + assert(result, ComputeFeeResponseStruct); + + return result; + } + + /** + * Handles the input of a stake amount. Checks if the user has enough of the asset + * to do the stake. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleOnStakeAmountInput(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + OnStakeAmountInputRequestStruct, + new InvalidParamsError(), + ); + + const { accountId, assetId, value } = request.params; + + await this.#accountsService.findByIdOrThrow(accountId); + const asset = await this.#assetsService.getAssetByAccountId( + accountId, + assetId, + ); + + /** + * If the account doesn't have this asset, treat it as having zero balance + */ + const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const requestBalance = new BigNumber(value); + + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + return { + valid: true, + errors: [], + }; + } + + /** + * Handles the confirmation of a stake. Checks if the user has enough of the asset + * to do the stake and then stakes the asset. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleConfirmStake(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + OnConfirmStakeRequestStruct, + new InvalidParamsError(), + ); + + const { + fromAccountId, + assetId, + value, + options: { purpose, srNodeAddress }, + } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const asset = await this.#assetsService.getAssetByAccountId( + fromAccountId, + assetId, + ); + + const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const requestBalance = new BigNumber(value); + /** + * Check if account has enough of the asset... + */ + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + /** + * All good. Let's stake. + */ + await this.#stakingService.stake({ + account, + assetId: assetId as NativeCaipAssetType, + amount: requestBalance, + purpose, + srNodeAddress, + }); + + return { + valid: true, + errors: [], + }; + } + + /** + * Check if we have enough of the asset to unstake. Keep in mind + * that you can unstake TRX that is allocated for Bandwidth or for Energy. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleOnUnstakeAmountInput(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + OnUnstakeAmountInputRequestStruct, + new InvalidParamsError(), + ); + + const { + accountId, + assetId, + value, + options: { purpose }, + } = request.params; + + /** + * We convert the `slip44:195` to `slip44:195-staked-for-bandwidth` or `slip44:195-staked-for-energy` + * depending on the purpose. + */ + const stakedAssetId = `${assetId}-staked-for-${purpose.toLowerCase()}`; + + await this.#accountsService.findByIdOrThrow(accountId); + const asset = await this.#assetsService.getAssetByAccountId( + accountId, + stakedAssetId, + ); + + const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const requestBalance = new BigNumber(value); + + /** + * Check if account has enough of the asset... + */ + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + return { + valid: true, + errors: [], + }; + } + + /** + * Handles the confirmation of an unstake. Checks if the user has enough of the asset + * to do the unstake. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleConfirmUnstake(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + OnConfirmUnstakeRequestStruct, + new InvalidParamsError(), + ); + + const { + accountId, + assetId, + value, + options: { purpose }, + } = request.params; + + /** + * We convert the `slip44:195-staked-for-bandwidth` or `slip44:195-staked-for-energy` to `slip44:195` + * depending on the purpose. + */ + const stakedAssetId = + `${assetId}-staked-for-${purpose.toLowerCase()}` as StakedCaipAssetType; + + const account = await this.#accountsService.findByIdOrThrow(accountId); + + const asset = await this.#assetsService.getAssetByAccountId( + accountId, + stakedAssetId, + ); + + const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const requestBalance = new BigNumber(value); + + /** + * Check if account has enough of the asset... + */ + if (requestBalance.isGreaterThan(accountBalance)) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + /** + * All good. Let's unstake. + */ + await this.#stakingService.unstake({ + account, + assetId: stakedAssetId, + amount: requestBalance, + }); + + return { + valid: true, + errors: [], + }; + } + + /** + * Claims TRX that has completed the 14-day unstaking lock period. + * Uses the WithdrawExpireUnfreezeContract on the Tron network. + * + * Shows a confirmation dialog before signing and broadcasting. + * + * @param request - The JSON-RPC request containing the account and asset details. + * @returns The result indicating success or failure with errors. + * @throws {UserRejectedRequestError} If the user rejects the confirmation. + */ + async #handleClaimUnstakedTrx(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + ClaimUnstakedTrxRequestStruct, + new InvalidParamsError(), + ); + + const { fromAccountId, assetId } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const { chainId } = parseCaipAssetType(assetId); + const scope = chainId as Network; + + const confirmed = await this.#confirmationHandler.confirmClaimUnstakedTrx({ + account, + scope, + }); + + if (!confirmed) { + throw new UserRejectedRequestError() as Error; + } + + await this.#stakingService.claimUnstakedTrx({ account, scope }); + + return { + valid: true, + errors: [], + }; + } + + /** + * Claims accrued voting/staking rewards. + * Uses the WithdrawBalanceContract on the Tron network. + * + * @param request - The JSON-RPC request containing the account and asset details. + * @returns The result indicating success or failure with errors. + */ + async #handleClaimTrxStakingRewards(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + ClaimTrxStakingRewardsRequestStruct, + new InvalidParamsError(), + ); + + const { fromAccountId, assetId } = request.params; + + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const { chainId } = parseCaipAssetType(assetId); + const scope = chainId as Network; + + await this.#stakingService.claimTrxStakingRewards({ account, scope }); + + return { + valid: true, + errors: [], + }; + } + + /** + * Handles signing a rewards message without confirmation. + * + * @param request - The JSON-RPC request containing the message to sign. + * @returns The signature, signed message, and signature type. + */ + async #handleSignRewardsMessage(request: JsonRpcRequest): Promise { + assertOrThrow( + request, + SignRewardsMessageRequestStruct, + new InvalidParamsError(), + ); + + const { + params: { accountId, message }, + } = request; + + const account = await this.#accountsService.findById(accountId); + if (!account) { + throw new InvalidParamsError(`Account not found: ${accountId}`) as Error; + } + + // Parse the rewards message to extract the address + const { address: messageAddress } = parseRewardsMessage(message); + + // Validate that the address in the message matches the signing account + if (messageAddress !== account.address) { + throw new InvalidParamsError( + `Address in rewards message (${messageAddress}) does not match signing account address (${account.address})`, + ) as Error; + } + + // Derive the private key for signing + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + // Create a TronWeb instance for message signing + // We can use any network scope since we're just signing a message + const tronWeb = this.#tronWebFactory.createClient( + Network.Mainnet, + privateKeyHex, + ); + + // Decode the base64 message to get the raw message + // eslint-disable-next-line no-restricted-globals + const decodedMessage = Buffer.from(message, 'base64').toString('utf8'); + + // Sign the message using TronWeb's signMessageV2 + const signature = tronWeb.trx.signMessageV2(decodedMessage, privateKeyHex); + + return { + signature, + signedMessage: message, + signatureType: 'secp256k1', + }; + } + + /** + * Sets the fee limit on a transaction's raw data and re-serializes it to hexadecimal format. + * + * This method mutates the provided rawData object by setting its fee_limit field, + * then converts the transaction to protocol buffer format and back to hex encoding. + * This is necessary when adjusting fee limits for transactions that need higher + * gas limits (e.g., complex smart contract interactions or swaps). + * + * @param tronWeb - The TronWeb client instance used for transaction serialization. + * @param rawData - The raw transaction data object to modify. This object will be mutated. + * @param feeLimit - The fee limit to set in SUN (1 TRX = 1,000,000 SUN). Defaults to FEE_LIMIT (100 TRX). + * @returns The hexadecimal-encoded raw data with the updated fee limit. + */ + #setRawDataFeeLimit( + tronWeb: TronWeb, + rawData: TransactionRawData, + feeLimit = FEE_LIMIT, + ): string { + rawData.fee_limit = feeLimit; + // Re-serialize rawData to get the updated rawDataHex + const transactionPb = tronWeb.utils.transaction.txJsonToPb({ + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: rawData, + }); + return tronWeb.utils.transaction.txPbToRawDataHex(transactionPb); + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts new file mode 100644 index 00000000..1b9e28ca --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/types.ts @@ -0,0 +1,32 @@ +export enum ClientRequestMethod { + SignAndSendTransaction = 'signAndSendTransaction', + /** + * Unified non-EVM Send + */ + ConfirmSend = 'confirmSend', + ComputeFee = 'computeFee', + OnAddressInput = 'onAddressInput', + OnAmountInput = 'onAmountInput', + /** + * Staking + Unstaking + */ + ComputeStakeFee = 'computeStakeFee', + OnStakeAmountInput = 'onStakeAmountInput', + ConfirmStake = 'confirmStake', + OnUnstakeAmountInput = 'onUnstakeAmountInput', + ConfirmUnstake = 'confirmUnstake', + ClaimUnstakedTrx = 'claimUnstakedTrx', + ClaimTrxStakingRewards = 'claimTrxStakingRewards', + /** + * Sign Rewards Message + */ + SignRewardsMessage = 'signRewardsMessage', +} + +export enum SendErrorCodes { + // eslint-disable-next-line @typescript-eslint/no-shadow + Required = 'Required', + Invalid = 'Invalid', + InsufficientBalance = 'InsufficientBalance', + InsufficientBalanceToCoverFee = 'InsufficientBalanceToCoverFee', +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts new file mode 100644 index 00000000..2ad6aa20 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts @@ -0,0 +1,350 @@ +import { AssetStruct, FeeType } from '@metamask/keyring-api'; +import { literal } from '@metamask/snaps-sdk'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + enums, + is, + number, + object, + optional, + refine, + string, +} from '@metamask/superstruct'; +import { + CaipAssetTypeStruct, + JsonRpcIdStruct, + JsonRpcVersionStruct, +} from '@metamask/utils'; + +import { ClientRequestMethod, SendErrorCodes } from './types'; +import { NativeCaipAssetTypeStruct } from '../../services/assets/types'; +import { + Base64Struct, + PositiveNumberStringStruct, + ScopeStringStruct, + TronAddressStruct, + UuidStruct, +} from '../../validation/structs'; + +/** + * signAndSendTransaction request/response validation. + */ +export const SignAndSendTransactionRequestParamsStruct = object({ + transaction: Base64Struct, + accountId: UuidStruct, + scope: ScopeStringStruct, + options: object({ + visible: optional(boolean()), + type: string(), + }), +}); + +export const SignAndSendTransactionRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignAndSendTransaction), + params: SignAndSendTransactionRequestParamsStruct, +}); + +/** + * onConfirmSend request/response validation. + */ +export const OnConfirmSendRequestParamsStruct = object({ + fromAccountId: UuidStruct, + toAddress: TronAddressStruct, + amount: PositiveNumberStringStruct, + assetId: CaipAssetTypeStruct, +}); + +export const OnConfirmSendRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ConfirmSend), + params: OnConfirmSendRequestParamsStruct, +}); + +/** + * onAddressInput request/response validation. + */ +export const OnAddressInputRequestParamsStruct = object({ + value: TronAddressStruct, +}); + +export const OnAddressInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnAddressInput), + params: OnAddressInputRequestParamsStruct, +}); + +/** + * onAmountInput request/response validation. + */ +export const OnAmountInputRequestParamsStruct = object({ + accountId: UuidStruct, + assetId: CaipAssetTypeStruct, + value: PositiveNumberStringStruct, + toAddress: optional(string()), +}); + +export const OnAmountInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnAmountInput), + params: OnAmountInputRequestParamsStruct, +}); + +export const ValidationResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: enums(Object.values(SendErrorCodes)), + }), + ), +}); + +export type ValidationResponse = Infer; + +/** + * computeFee request/response validation. + */ +export const ComputeFeeRequestParamsStruct = object({ + transaction: Base64Struct, + accountId: UuidStruct, + scope: ScopeStringStruct, + options: object({ + visible: optional(boolean()), + type: string(), + feeLimit: optional(number()), + }), +}); + +export const OnStakeAmountInputRequestParamsStruct = object({ + accountId: UuidStruct, + assetId: NativeCaipAssetTypeStruct, + value: PositiveNumberStringStruct, +}); + +export const OnStakeAmountInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnStakeAmountInput), + params: OnStakeAmountInputRequestParamsStruct, +}); + +export const OnConfirmStakeRequestParamsStruct = object({ + fromAccountId: UuidStruct, + assetId: NativeCaipAssetTypeStruct, + value: PositiveNumberStringStruct, + options: object({ + purpose: enums(['ENERGY', 'BANDWIDTH']), + /** + * Optional SR node address to allocate votes to. + * If not provided, defaults to the Consensys SR node. + */ + srNodeAddress: optional(TronAddressStruct), + }), +}); + +export const OnConfirmStakeRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ConfirmStake), + params: OnConfirmStakeRequestParamsStruct, +}); + +export const ComputeFeeRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ComputeFee), + params: ComputeFeeRequestParamsStruct, +}); + +export const ComputeFeeResponseStruct = array( + object({ + type: enums(Object.values(FeeType)), + asset: AssetStruct, + }), +); + +export type ComputeFeeResponse = Infer; + +/** + * computeStakeFee request validation. + */ +export const ComputeStakeFeeRequestParamsStruct = object({ + fromAccountId: UuidStruct, + value: PositiveNumberStringStruct, + options: object({ + purpose: enums(['ENERGY', 'BANDWIDTH']), + }), +}); + +export const ComputeStakeFeeRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ComputeStakeFee), + params: ComputeStakeFeeRequestParamsStruct, +}); + +export const OnUnstakeAmountInputRequestParamsStruct = object({ + accountId: UuidStruct, + assetId: NativeCaipAssetTypeStruct, + options: object({ + purpose: enums(['ENERGY', 'BANDWIDTH']), + }), + value: PositiveNumberStringStruct, +}); + +export const OnUnstakeAmountInputRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.OnUnstakeAmountInput), + params: OnUnstakeAmountInputRequestParamsStruct, +}); + +export const OnConfirmUnstakeRequestParamsStruct = object({ + accountId: UuidStruct, + assetId: NativeCaipAssetTypeStruct, + options: object({ + purpose: enums(['ENERGY', 'BANDWIDTH']), + }), + value: PositiveNumberStringStruct, +}); + +export const OnConfirmUnstakeRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ConfirmUnstake), + params: OnConfirmUnstakeRequestParamsStruct, +}); + +/** + * claimUnstakedTrx request validation. + */ +export const ClaimUnstakedTrxRequestParamsStruct = object({ + fromAccountId: UuidStruct, + assetId: NativeCaipAssetTypeStruct, +}); + +export const ClaimUnstakedTrxRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ClaimUnstakedTrx), + params: ClaimUnstakedTrxRequestParamsStruct, +}); + +/** + * claimTrxStakingRewards request validation. + */ +export const ClaimTrxStakingRewardsRequestParamsStruct = object({ + fromAccountId: UuidStruct, + assetId: NativeCaipAssetTypeStruct, +}); + +export const ClaimTrxStakingRewardsRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.ClaimTrxStakingRewards), + params: ClaimTrxStakingRewardsRequestParamsStruct, +}); + +/** + * Parses a base64-encoded rewards message. + * Expected format: 'rewards,{address},{timestamp}' + * + * @param base64Message - The base64-encoded message to parse. + * @returns The parsed address and timestamp. + * @throws Error if the message format is invalid. + */ +export function parseRewardsMessage(base64Message: string): { + address: string; + timestamp: number; +} { + // Decode the message from base64 to utf8 + // eslint-disable-next-line no-restricted-globals + const decodedMessage = Buffer.from(base64Message, 'base64').toString('utf8'); + + // Check if message starts with 'rewards,' + if (!decodedMessage.startsWith('rewards,')) { + throw new Error('Message must start with "rewards,"'); + } + + // Split the message into parts + const parts = decodedMessage.split(','); + if (parts.length !== 3) { + throw new Error( + 'Message must have exactly 3 parts: rewards,{address},{timestamp}', + ); + } + + const [prefix, addressPart, timestampPart] = parts; + + // Validate prefix (already checked above, but being explicit) + if (prefix !== 'rewards') { + throw new Error('Message must start with "rewards"'); + } + + // Validate Tron address + if (!is(addressPart, TronAddressStruct)) { + throw new Error('Invalid Tron address'); + } + + // Validate timestamp + if (!is(timestampPart, PositiveNumberStringStruct)) { + throw new Error('Invalid timestamp format'); + } + + // Ensure timestamp is an integer (no decimals) + if (timestampPart.includes('.')) { + throw new Error('Invalid timestamp'); + } + + const timestamp = parseInt(timestampPart, 10); + if (timestamp <= 0) { + throw new Error('Invalid timestamp'); + } + + return { + address: addressPart, + timestamp, + }; +} + +/** + * Validates that a base64-encoded message follows the rewards format: + * 'rewards,{address},{timestamp}' + * - Must be valid base64 + * - When decoded, must start with 'rewards,' + * - Must contain a valid Tron address + * - Must contain a valid timestamp + */ +export const RewardsMessageStruct = refine( + Base64Struct, + 'RewardsMessage', + (value: string) => { + try { + parseRewardsMessage(value); + return true; + } catch (error) { + return error instanceof Error ? error.message : 'Invalid rewards message'; + } + }, +); + +/** + * signRewardsMessage request/response validation. + */ +export const SignRewardsMessageRequestParamsStruct = object({ + accountId: UuidStruct, + message: RewardsMessageStruct, +}); + +export const SignRewardsMessageRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignRewardsMessage), + params: SignRewardsMessageRequestParamsStruct, +}); diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.test.tsx b/merged-packages/tron-wallet-snap/src/handlers/cronjob.test.tsx new file mode 100644 index 00000000..d3070441 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.test.tsx @@ -0,0 +1,780 @@ +import { BackgroundEventMethod, CronHandler } from './cronjob'; +import type { PriceApiClient } from '../clients/price-api/PriceApiClient'; +import type { SnapClient } from '../clients/snap/SnapClient'; +import type { TronHttpClient } from '../clients/tron-http/TronHttpClient'; +import type { TronWebFactory } from '../clients/tronweb/TronWebFactory'; +import { Network } from '../constants'; +import type { AccountsService } from '../services/accounts/AccountsService'; +import type { State, UnencryptedStateValue } from '../services/state/State'; +import { TransactionExpirationRefresherService } from '../services/transaction-expiration-refresher/TransactionExpirationRefresherService'; +import type { JsonTransactionRawData } from '../services/transaction-expiration-refresher/types'; +import type { TransactionScanService } from '../services/transaction-scan/TransactionScanService'; +import { + SimulationStatus, + type TransactionScanResult, +} from '../services/transaction-scan/types'; +import { FetchStatus } from '../types/snap'; +import { + CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME, + type ConfirmSignTransactionContext, +} from '../ui/confirmation/views/ConfirmSignTransaction/types'; +import type { ConfirmTransactionRequestContext } from '../ui/confirmation/views/ConfirmTransactionRequest/types'; +import type { ILogger } from '../utils/logger'; + +/** + * Subset of SnapClient methods exercised by `refreshConfirmationSend`. + */ +type MockSnapClient = jest.Mocked< + Pick< + SnapClient, + | 'getClientStatus' + | 'createInterface' + | 'showDialog' + | 'updateInterface' + | 'getInterfaceContext' + | 'scheduleBackgroundEvent' + | 'getPreferences' + > +>; + +/** + * Subset of State methods exercised by `refreshConfirmationSend`. + */ +type MockState = { + getKey: jest.Mock; + setKey: jest.Mock; + setKeyWith: jest.Mock; +}; + +/** + * Subset of TransactionScanService methods exercised by + * `refreshConfirmationSend`. + */ +type MockTransactionScanService = jest.Mocked< + Pick< + TransactionScanService, + 'scanTransaction' | 'getSecurityAlertDescription' + > +>; + +type MockTronWebFactory = jest.Mocked>; + +type InterfaceContext = + | ConfirmTransactionRequestContext + | ConfirmSignTransactionContext; + +const MOCK_BLOCK_TIMESTAMP = 1_700_000_000_000; + +const getRefBlockBytes = (number: number) => + number.toString(16).slice(-4).padStart(4, '0'); + +const createBlock = ({ + number, + timestamp, + hashSegment = '1122334455667788', +}: { + number: number; + timestamp: number; + hashSegment?: string; +}) => ({ + blockID: `${'0'.repeat(16)}${hashSegment}${'f'.repeat(32)}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + block_header: { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + number, + timestamp, + }, + }, +}); + +/** + * Builds a mock scan result for use in tests. + * + * @param overrides - Optional overrides for the scan result. + * @returns A mock TransactionScanResult. + */ +function buildMockScanResult( + overrides: Partial = {}, +): TransactionScanResult { + return { + status: 'SUCCESS', + simulationStatus: SimulationStatus.Completed, + estimatedChanges: { + assets: [ + { + type: 'out', + value: '1000000', + price: '0.1', + symbol: 'TRX', + name: 'Tron', + logo: null, + assetType: 'TRC20', + }, + ], + }, + validation: { type: 'Benign', reason: null }, + error: null, + ...overrides, + }; +} + +/** + * Builds a mock interface context for the send confirmation dialog. + * + * @param overrides - Optional overrides for context fields. + * @returns A mock ConfirmTransactionRequestContext. + */ +function buildMockInterfaceContext( + overrides: Partial = {}, +): ConfirmTransactionRequestContext { + return { + origin: 'MetaMask', + scope: Network.Mainnet, + fromAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + toAddress: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + amount: '1', + fees: [], + asset: { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: 'account-1', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '10000000', + uiAmount: '10', + iconUrl: '', + }, + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }, + networkImage: '', + tokenPrices: {}, + tokenPricesFetchStatus: FetchStatus.Fetched, + scan: null, + scanFetchStatus: FetchStatus.Initial, + + transactionRawData: { + contract: [ + { + type: 'TransferContract', + parameter: { + type_url: 'type.googleapis.com/protocol.TransferContract', // eslint-disable-line @typescript-eslint/naming-convention + value: { + owner_address: '41a2155e688b2baebdfdacd073ba79f5b22946aacf', // eslint-disable-line @typescript-eslint/naming-convention + to_address: '4132f9c0c487f21716b7a8f12906b752889902655', // eslint-disable-line @typescript-eslint/naming-convention + amount: 1000000, + }, + }, + }, + ], + ref_block_bytes: '', // eslint-disable-line @typescript-eslint/naming-convention + ref_block_hash: '', // eslint-disable-line @typescript-eslint/naming-convention + expiration: 0, + timestamp: 0, + } as any, + accountType: 'tron:eoa', + ...overrides, + }; +} + +/** + * Builds a mock interface context for the signTransaction confirmation dialog. + * + * @param overrides - Optional overrides for context fields. + * @returns A mock ConfirmSignTransactionContext. + */ +function buildMockSignTransactionInterfaceContext( + overrides: Partial = {}, +): ConfirmSignTransactionContext { + return { + scope: Network.Mainnet, + account: { + id: 'account-1', + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + options: {}, + methods: ['signMessage', 'signTransaction'], + type: 'tron:eoa', + scopes: [Network.Mainnet], + entropySource: 'entropy-source-1' as any, + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }, + transaction: { + rawDataHex: '0a02beef', + type: 'TransferContract', + }, + origin: 'https://example.com', + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }, + networkImage: '', + scan: null, + scanFetchStatus: FetchStatus.Initial, + tokenPrices: {}, + tokenPricesFetchStatus: FetchStatus.Fetched, + fees: [], + feesFetchStatus: FetchStatus.Fetched, + ...overrides, + }; +} + +/** + * Builds a mock logger satisfying the ILogger interface. + * + * @returns A mock ILogger. + */ +function buildMockLogger(): ILogger { + return { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }; +} + +/** + * Builds a mock SnapClient with only the methods exercised by the + * `refreshConfirmationSend` flow. + * + * @param interfaceContext - The value `getInterfaceContext` resolves to. + * @returns A mock SnapClient. + */ +function buildMockSnapClient( + interfaceContext: InterfaceContext | null, +): MockSnapClient { + return { + getClientStatus: jest + .fn() + .mockResolvedValue({ active: true, locked: false }), + createInterface: jest.fn().mockResolvedValue('interface-id'), + showDialog: jest.fn().mockResolvedValue(true), + updateInterface: jest.fn().mockResolvedValue(undefined), + getInterfaceContext: jest.fn().mockResolvedValue(interfaceContext), + scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), + getPreferences: jest.fn().mockResolvedValue({}), + }; +} + +/** + * Builds a mock State with only the methods exercised by the + * `refreshConfirmationSend` flow. + * + * @param mapInterfaceNameToId - The value `getKey` resolves to. + * @returns A mock State. + */ +function buildMockState( + mapInterfaceNameToId: Record, +): MockState { + return { + setKey: jest.fn().mockResolvedValue(undefined), + setKeyWith: jest.fn().mockResolvedValue(undefined), + getKey: jest.fn().mockResolvedValue(mapInterfaceNameToId), + }; +} + +/** + * Builds a mock TransactionScanService with only the methods exercised by + * the `refreshConfirmationSend` flow. + * + * @param scanResult - The value `scanTransaction` resolves to. + * @returns A mock TransactionScanService. + */ +function buildMockTransactionScanService( + scanResult: TransactionScanResult, +): MockTransactionScanService { + return { + scanTransaction: jest.fn().mockResolvedValue(scanResult), + getSecurityAlertDescription: jest.fn().mockReturnValue('description'), + }; +} + +/** + * Builds a mock TronWebFactory for transaction metadata refresh tests. + * + * @param options - The mock block options. + * @param options.currentBlock - The block returned by getCurrentBlock. + * @param options.referencedBlock - The block returned by getBlockByNumber. + * @param options.currentBlockError - The error thrown by getCurrentBlock. + * @returns A mock TronWebFactory. + */ +function buildMockTronWebFactory({ + currentBlock, + currentBlockError, + referencedBlock, +}: { + currentBlock: ReturnType; + currentBlockError?: Error; + referencedBlock?: ReturnType; +}): MockTronWebFactory { + return { + createClient: jest.fn().mockReturnValue({ + trx: { + getCurrentBlock: currentBlockError + ? jest.fn().mockRejectedValue(currentBlockError) + : jest.fn().mockResolvedValue(currentBlock), + getBlockByNumber: jest + .fn() + .mockResolvedValue(referencedBlock ?? currentBlock), + }, + utils: { + deserializeTx: { + deserializeTransaction: jest.fn().mockReturnValue({ + contract: [ + { + type: 'TransferContract', + parameter: { + type_url: 'type.googleapis.com/protocol.TransferContract', // eslint-disable-line @typescript-eslint/naming-convention + value: { + owner_address: '41a2155e688b2baebdfdacd073ba79f5b22946aacf', // eslint-disable-line @typescript-eslint/naming-convention + to_address: '4132f9c0c487f21716b7a8f12906b752889902655', // eslint-disable-line @typescript-eslint/naming-convention + amount: 1000000, + }, + }, + }, + ], + ref_block_bytes: '0001', // eslint-disable-line @typescript-eslint/naming-convention + ref_block_hash: 'outdatedhash', // eslint-disable-line @typescript-eslint/naming-convention + expiration: currentBlock.block_header.raw_data.timestamp - 1, + timestamp: currentBlock.block_header.raw_data.timestamp - 60_000, + }), + }, + transaction: { + txJsonToPb: jest + .fn() + .mockImplementation((transaction) => transaction), + txPbToRawDataHex: jest.fn().mockReturnValue('refreshed-raw-data-hex'), + txPbToTxID: jest.fn().mockReturnValue('0xrefreshed-tx-id'), + }, + }, + }), + }; +} + +/** + * Assembles a CronHandler from the given partial mocks. Type assertions are + * concentrated here so that every other part of the test file stays + * assertion-free. + * + * @param deps - The mock dependencies. + * @param deps.mockSnapClient - The mock SnapClient. + * @param deps.mockState - The mock State. + * @param deps.mockTransactionScanService - The mock TransactionScanService. + * @param deps.transactionExpirationRefresherService - The transaction metadata refresher. + * @returns A CronHandler instance wired to the mocks. + */ +function buildCronHandler({ + mockSnapClient, + mockState, + mockTransactionScanService, + transactionExpirationRefresherService, +}: { + mockSnapClient: MockSnapClient; + mockState: MockState; + mockTransactionScanService: MockTransactionScanService; + transactionExpirationRefresherService: Pick< + TransactionExpirationRefresherService, + 'ensureFreshRawData' | 'ensureFreshSerializedTransaction' + >; +}): CronHandler { + return new CronHandler({ + logger: buildMockLogger(), + accountsService: {} as AccountsService, + snapClient: mockSnapClient as unknown as SnapClient, + state: mockState as unknown as State, + priceApiClient: {} as PriceApiClient, + tronHttpClient: {} as TronHttpClient, + transactionScanService: + mockTransactionScanService as unknown as TransactionScanService, + transactionExpirationRefresherService: + transactionExpirationRefresherService as unknown as TransactionExpirationRefresherService, + }); +} + +/** + * The callback that `withCronHandler` calls. + */ +type WithCronHandlerCallback = (payload: { + cronHandler: CronHandler; + mockSnapClient: MockSnapClient; + mockState: MockState; + mockTransactionScanService: MockTransactionScanService; + mockTronWebFactory: MockTronWebFactory; +}) => Promise | void; + +type MockTronWebWithTransactionRebuild = { + utils: { + transaction: { + txJsonToPb: jest.Mock; + }; + }; +}; + +/** + * Options for the `withCronHandler` factory function. + */ +type WithCronHandlerOptions = { + interfaceContext?: InterfaceContext | null; + scanResult?: TransactionScanResult; + mapInterfaceNameToId?: Record; + currentBlock?: ReturnType; + currentBlockError?: Error; + referencedBlock?: ReturnType; + refreshRawData?: boolean; +}; + +/** + * Constructs a CronHandler with sensible defaults and calls the given test + * function with the handler and all mocks. Overrides can be provided to + * configure the mocks for specific test scenarios. + * + * @param args - Either a function, or an options bag + a function. + */ +async function withCronHandler( + ...args: + | [WithCronHandlerCallback] + | [WithCronHandlerOptions, WithCronHandlerCallback] +): Promise { + const [options, testFunction] = args.length === 2 ? args : [{}, args[0]]; + + const { + interfaceContext = buildMockInterfaceContext(), + scanResult = buildMockScanResult(), + mapInterfaceNameToId = { confirmTransaction: 'interface-id-456' }, + currentBlock = createBlock({ + number: 200_000, + timestamp: MOCK_BLOCK_TIMESTAMP, + }), + currentBlockError, + referencedBlock, + refreshRawData = false, + } = options; + + const mockSnapClient = buildMockSnapClient(interfaceContext); + const mockState = buildMockState(mapInterfaceNameToId); + const mockTransactionScanService = + buildMockTransactionScanService(scanResult); + const mockTronWebFactory = buildMockTronWebFactory({ + currentBlock, + currentBlockError, + referencedBlock, + }); + const transactionExpirationRefresherService = + new TransactionExpirationRefresherService({ + tronWebFactory: mockTronWebFactory as unknown as TronWebFactory, + }); + const passThroughTransactionExpirationRefresherService = { + ensureFreshRawData: jest.fn(async ({ rawData }) => rawData), + ensureFreshSerializedTransaction: + transactionExpirationRefresherService.ensureFreshSerializedTransaction.bind( + transactionExpirationRefresherService, + ), + }; + + const cronHandler = buildCronHandler({ + mockSnapClient, + mockState, + mockTransactionScanService, + transactionExpirationRefresherService: refreshRawData + ? transactionExpirationRefresherService + : passThroughTransactionExpirationRefresherService, + }); + + await testFunction({ + cronHandler, + mockSnapClient, + mockState, + mockTransactionScanService, + mockTronWebFactory, + }); +} + +describe('CronHandler', () => { + describe('refreshConfirmationSend', () => { + it('refreshes security scan and updates interface', async () => { + await withCronHandler( + async ({ cronHandler, mockSnapClient, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).toHaveBeenCalledWith( + expect.objectContaining({ + accountAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + options: ['simulation', 'validation'], + }), + ); + + // Verify interface was updated (fetching state + final state) + expect(mockSnapClient.updateInterface).toHaveBeenCalledTimes(2); + + // Verify next refresh was scheduled + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + }, + ); + }); + + it('refreshes transaction metadata before refreshing security scan', async () => { + const blockTimestamp = MOCK_BLOCK_TIMESTAMP; + const currentBlock = createBlock({ + number: 200_000, + timestamp: blockTimestamp, + hashSegment: '0011223344556677', + }); + const transactionRawData = structuredClone( + buildMockInterfaceContext().transactionRawData, + ) as JsonTransactionRawData; + transactionRawData.ref_block_bytes = '0001'; + transactionRawData.ref_block_hash = 'outdatedhash'; + transactionRawData.expiration = blockTimestamp - 1; + transactionRawData.timestamp = blockTimestamp - 60_000; + const interfaceContext = buildMockInterfaceContext({ + transactionRawData, + }); + + await withCronHandler( + { currentBlock, interfaceContext, refreshRawData: true }, + async ({ cronHandler, mockSnapClient, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + const scanPayload = + mockTransactionScanService.scanTransaction.mock.calls[0]?.[0]; + const scannedRawData = scanPayload?.transactionRawData; + const finalUpdateCall = mockSnapClient.updateInterface.mock.calls[1]; + const finalContext = + finalUpdateCall?.[2] as ConfirmTransactionRequestContext; + + expect(scannedRawData).not.toBe(interfaceContext.transactionRawData); + expect(scannedRawData).toStrictEqual( + expect.objectContaining({ + ref_block_bytes: getRefBlockBytes(200_000), // eslint-disable-line @typescript-eslint/naming-convention + ref_block_hash: '0011223344556677', // eslint-disable-line @typescript-eslint/naming-convention + expiration: blockTimestamp + 60_000, + timestamp: blockTimestamp, + }), + ); + expect(finalContext.transactionRawData).toBe(scannedRawData); + }, + ); + }); + + it('exits early when no active interface exists', async () => { + await withCronHandler( + { mapInterfaceNameToId: {} }, + async ({ cronHandler, mockSnapClient, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + expect(mockSnapClient.updateInterface).not.toHaveBeenCalled(); + }, + ); + }); + + it('exits early when interface context no longer exists', async () => { + await withCronHandler( + { interfaceContext: null }, + async ({ cronHandler, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('skips refresh when required context fields are missing', async () => { + await withCronHandler( + { + interfaceContext: buildMockInterfaceContext({ fromAddress: null }), + }, + async ({ cronHandler, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('handles scan failure gracefully and sets error state', async () => { + await withCronHandler( + async ({ cronHandler, mockSnapClient, mockTransactionScanService }) => { + mockTransactionScanService.scanTransaction.mockRejectedValue( + new Error('Scan API error'), + ); + + await cronHandler.refreshConfirmationSend(); + + // Should still update interface with error status + expect(mockSnapClient.updateInterface).toHaveBeenCalledTimes(2); + + // The final update should have scanFetchStatus: FetchStatus.Error + const lastUpdateCall = mockSnapClient.updateInterface.mock.calls[1]; + const contextArg = lastUpdateCall?.[2] as any; + expect(contextArg?.scanFetchStatus).toBe(FetchStatus.Error); + }, + ); + }); + + it('exits gracefully when interface closes during refresh', async () => { + const context = buildMockInterfaceContext(); + await withCronHandler( + { interfaceContext: context }, + async ({ cronHandler, mockSnapClient }) => { + // First call returns context (initial check), second returns null (closed during scan) + mockSnapClient.getInterfaceContext + .mockResolvedValueOnce(context) + .mockResolvedValueOnce(null); + + await cronHandler.refreshConfirmationSend(); + + // Should not schedule next refresh + expect(mockSnapClient.scheduleBackgroundEvent).not.toHaveBeenCalled(); + }, + ); + }); + + it('includes only simulation when useSecurityAlerts is false', async () => { + await withCronHandler( + { + interfaceContext: buildMockInterfaceContext({ + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: false, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }, + }), + }, + async ({ cronHandler, mockTransactionScanService }) => { + await cronHandler.refreshConfirmationSend(); + + expect( + mockTransactionScanService.scanTransaction, + ).toHaveBeenCalledWith( + expect.objectContaining({ + options: ['simulation'], + }), + ); + }, + ); + }); + }); + + describe('refreshSignTransaction', () => { + it('refreshes transaction metadata before refreshing security scan', async () => { + const blockTimestamp = MOCK_BLOCK_TIMESTAMP; + const currentBlock = createBlock({ + number: 200_000, + timestamp: blockTimestamp, + hashSegment: '0011223344556677', + }); + const interfaceContext = buildMockSignTransactionInterfaceContext(); + + await withCronHandler( + { + currentBlock, + interfaceContext, + mapInterfaceNameToId: { + [CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME]: 'interface-id-456', + }, + }, + async ({ + cronHandler, + mockSnapClient, + mockTransactionScanService, + mockTronWebFactory, + }) => { + await cronHandler.refreshSignTransaction(); + + const scanPayload = + mockTransactionScanService.scanTransaction.mock.calls[0]?.[0]; + const scannedRawData = scanPayload?.transactionRawData; + const mockTronWeb = mockTronWebFactory.createClient.mock.results[0] + ?.value as MockTronWebWithTransactionRebuild; + const transactionForRebuild = + mockTronWeb.utils.transaction.txJsonToPb.mock.calls[0]?.[0]; + const finalUpdateCall = mockSnapClient.updateInterface.mock.calls[1]; + const finalContext = + finalUpdateCall?.[2] as ConfirmSignTransactionContext; + + expect(scannedRawData).toStrictEqual( + expect.objectContaining({ + ref_block_bytes: getRefBlockBytes(200_000), // eslint-disable-line @typescript-eslint/naming-convention + ref_block_hash: '0011223344556677', // eslint-disable-line @typescript-eslint/naming-convention + expiration: blockTimestamp + 60_000, + timestamp: blockTimestamp, + }), + ); + expect(transactionForRebuild.txID).not.toBe(''); + expect(finalContext.transaction.rawDataHex).toBe( + 'refreshed-raw-data-hex', + ); + }, + ); + }); + + it('sets error state when transaction metadata refresh fails', async () => { + await withCronHandler( + { + currentBlockError: new Error('node offline'), + interfaceContext: buildMockSignTransactionInterfaceContext(), + mapInterfaceNameToId: { + [CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME]: 'interface-id-456', + }, + }, + async ({ cronHandler, mockSnapClient, mockTransactionScanService }) => { + await cronHandler.refreshSignTransaction(); + + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + expect(mockSnapClient.updateInterface).toHaveBeenCalledTimes(2); + + const finalUpdateCall = mockSnapClient.updateInterface.mock.calls[1]; + const finalContext = + finalUpdateCall?.[2] as ConfirmSignTransactionContext; + + expect(finalContext.scan).toBeNull(); + expect(finalContext.scanFetchStatus).toBe(FetchStatus.Error); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshSignTransaction, + duration: 'PT20S', + }); + }, + ); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/handlers/cronjob.tsx b/merged-packages/tron-wallet-snap/src/handlers/cronjob.tsx new file mode 100644 index 00000000..b2027df8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/cronjob.tsx @@ -0,0 +1,704 @@ +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; + +import type { PriceApiClient } from '../clients/price-api/PriceApiClient'; +import type { SnapClient } from '../clients/snap/SnapClient'; +import type { TronHttpClient } from '../clients/tron-http/TronHttpClient'; +import type { Network } from '../constants'; +import type { TronKeyringAccount } from '../entities/keyring-account'; +import type { AccountsService } from '../services/accounts/AccountsService'; +import type { State, UnencryptedStateValue } from '../services/state/State'; +import type { TransactionExpirationRefresherService } from '../services/transaction-expiration-refresher/TransactionExpirationRefresherService'; +import type { JsonTransactionRawData } from '../services/transaction-expiration-refresher/types'; +import type { TransactionScanService } from '../services/transaction-scan/TransactionScanService'; +import { FetchStatus } from '../types/snap'; +import { ConfirmSignTransaction } from '../ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction'; +import { + CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME, + type ConfirmSignTransactionContext, +} from '../ui/confirmation/views/ConfirmSignTransaction/types'; +import { ConfirmTransactionRequest } from '../ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest'; +import { + CONFIRM_TRANSACTION_INTERFACE_NAME, + type ConfirmTransactionRequestContext, +} from '../ui/confirmation/views/ConfirmTransactionRequest/types'; +import type { ILogger } from '../utils/logger'; +import { createPrefixedLogger } from '../utils/logger'; + +export enum CronjobMethod { + ContinuouslySynchronizeSelectedAccounts = 'onSynchronizeSelectedAccountsCronjob', +} + +export enum BackgroundEventMethod { + SynchronizeSelectedAccounts = 'onSynchronizeSelectedAccounts', + SynchronizeAccounts = 'onSynchronizeAccounts', + SynchronizeAccount = 'onSynchronizeAccount', + SynchronizeAccountTransactions = 'onSynchronizeAccountTransactions', + RefreshConfirmationPrices = 'refreshConfirmationPrices', + RefreshConfirmationSend = 'refreshConfirmationSend', + RefreshSignTransaction = 'refreshSignTransaction', + TrackTransaction = 'onTrackTransaction', +} + +export class CronHandler { + readonly #logger: ILogger; + + readonly #accountsService: AccountsService; + + readonly #snapClient: SnapClient; + + readonly #state: State; + + readonly #priceApiClient: PriceApiClient; + + readonly #tronHttpClient: TronHttpClient; + + readonly #transactionScanService: TransactionScanService; + + readonly #transactionExpirationRefresherService: TransactionExpirationRefresherService; + + constructor({ + logger, + accountsService, + snapClient, + state, + priceApiClient, + tronHttpClient, + transactionScanService, + transactionExpirationRefresherService, + }: { + logger: ILogger; + accountsService: AccountsService; + snapClient: SnapClient; + state: State; + priceApiClient: PriceApiClient; + tronHttpClient: TronHttpClient; + transactionScanService: TransactionScanService; + transactionExpirationRefresherService: TransactionExpirationRefresherService; + }) { + this.#logger = createPrefixedLogger(logger, '[⏰ CronHandler]'); + this.#accountsService = accountsService; + this.#snapClient = snapClient; + this.#state = state; + this.#priceApiClient = priceApiClient; + this.#tronHttpClient = tronHttpClient; + this.#transactionScanService = transactionScanService; + this.#transactionExpirationRefresherService = + transactionExpirationRefresherService; + } + + async handle(request: JsonRpcRequest): Promise { + const { method, params } = request; + const { active, locked } = await this.#snapClient.getClientStatus(); + + if (!active || locked) { + return; + } + + switch (method as CronjobMethod | BackgroundEventMethod) { + case CronjobMethod.ContinuouslySynchronizeSelectedAccounts: + await this.synchronizeSelectedAccounts(); + break; + case BackgroundEventMethod.SynchronizeSelectedAccounts: + await this.synchronizeSelectedAccounts(); + break; + case BackgroundEventMethod.SynchronizeAccounts: + await this.synchronizeAccounts(params as { accountIds: string[] }); + break; + case BackgroundEventMethod.SynchronizeAccount: + await this.synchronizeAccount(params as { accountId: string }); + break; + case BackgroundEventMethod.SynchronizeAccountTransactions: + await this.synchronizeAccountTransactions( + params as { accountId: string }, + ); + break; + case BackgroundEventMethod.RefreshConfirmationPrices: + await this.refreshConfirmationPrices(); + break; + case BackgroundEventMethod.RefreshConfirmationSend: + await this.refreshConfirmationSend(); + break; + case BackgroundEventMethod.RefreshSignTransaction: + await this.refreshSignTransaction(); + break; + case BackgroundEventMethod.TrackTransaction: + await this.trackTransaction( + params as { + txId: string; + scope: Network; + accountIds: string[]; + attempt: number; + }, + ); + break; + default: + throw new Error(`Unknown cronjob method: ${method}`); + } + } + + async synchronizeSelectedAccounts(): Promise { + this.#logger.info('Synchronizing selected accounts...'); + + const accounts = await this.#accountsService.getAllSelected(); + + await this.#accountsService.synchronize(accounts); + } + + async synchronizeAccounts({ + accountIds, + }: { + accountIds: string[]; + }): Promise { + this.#logger.info(`Synchronizing accounts ${accountIds.join(', ')}...`); + + const accounts = await this.#accountsService.findByIds(accountIds); + + if (accounts.length === 0) { + return; + } + + await this.#accountsService.synchronize(accounts); + } + + async synchronizeAccount({ + accountId, + }: { + accountId: string; + }): Promise { + this.#logger.info(`Synchronizing account ${accountId}...`); + + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return; + } + + await this.#accountsService.synchronize([account]); + } + + async synchronizeAccountTransactions({ + accountId, + }: { + accountId: string; + }): Promise { + this.#logger.info(`Synchronizing account transactions ${accountId}...`); + + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return; + } + + await this.#accountsService.synchronizeTransactions([account]); + } + + /** + * Background job to refresh price data for transaction request confirmation dialogs. + * Follows Solana's pattern: get interface ID from map, extract data from context. + */ + async refreshConfirmationPrices(): Promise { + this.#logger.info('Background price refresh triggered for confirmation...'); + + const mapInterfaceNameToId = + (await this.#state.getKey( + 'mapInterfaceNameToId', + )) ?? {}; + + const transactionRequestInterfaceId = + mapInterfaceNameToId[CONFIRM_TRANSACTION_INTERFACE_NAME]; + + if (!transactionRequestInterfaceId) { + this.#logger.info('No active transaction request interface found'); + return; + } + + await this.#refreshTransactionRequestPrices(transactionRequestInterfaceId); + } + + /** + * Refresh prices for ConfirmTransactionRequest interface. + * + * @param confirmationInterfaceId - The interface ID to refresh. + */ + async #refreshTransactionRequestPrices( + confirmationInterfaceId: string, + ): Promise { + const interfaceContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!interfaceContext) { + this.#logger.info('Interface context no longer exists, skipping refresh'); + return; + } + + try { + const assetCaipIds = [ + interfaceContext.asset.assetType, + ...interfaceContext.fees.map((fee) => fee.asset.type), + ]; + const uniqueAssetCaipIds = [...new Set(assetCaipIds)]; + + const fetchingContext: ConfirmTransactionRequestContext = { + ...interfaceContext, + tokenPricesFetchStatus: FetchStatus.Fetching, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + fetchingContext, + ); + + this.#logger.info( + `Fetching fresh prices for ${uniqueAssetCaipIds.length} assets`, + ); + const prices = await this.#priceApiClient.getMultipleSpotPrices( + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + uniqueAssetCaipIds as any, + interfaceContext.preferences.currency, + ); + + const latestContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!latestContext) { + this.#logger.info( + 'Interface context no longer exists after fetch, skipping update', + ); + return; + } + + const updatedContext: ConfirmTransactionRequestContext = { + ...latestContext, + tokenPrices: prices, + tokenPricesFetchStatus: FetchStatus.Fetched, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + updatedContext, + ); + + this.#logger.info('Successfully refreshed confirmation prices'); + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationPrices, + duration: 'PT20S', + }); + } catch (error) { + this.#logger.warn({ error }, 'Could not refresh confirmation prices'); + } + } + + /** + * Background job to refresh the security scan for simple send confirmation dialogs. + */ + async refreshConfirmationSend(): Promise { + this.#logger.info( + 'Background scan refresh triggered for send confirmation...', + ); + + const mapInterfaceNameToId = + (await this.#state.getKey( + 'mapInterfaceNameToId', + )) ?? {}; + + const confirmationInterfaceId = + mapInterfaceNameToId[CONFIRM_TRANSACTION_INTERFACE_NAME]; + + if (!confirmationInterfaceId) { + this.#logger.info('No active send confirmation interface found'); + return; + } + + const interfaceContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!interfaceContext) { + this.#logger.info('Interface context no longer exists, skipping refresh'); + return; + } + + if (!interfaceContext.fromAddress || !interfaceContext.scope) { + this.#logger.info('Context is missing required fields for scan refresh'); + return; + } + + const { preferences, scope, fromAddress, origin } = interfaceContext; + const transactionRawData = + interfaceContext.transactionRawData as JsonTransactionRawData | null; + + if (!transactionRawData) { + this.#logger.info( + 'Context is missing transactionRawData for scan refresh', + ); + return; + } + + try { + const fetchingContext: ConfirmTransactionRequestContext = { + ...interfaceContext, + scanFetchStatus: FetchStatus.Fetching, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + fetchingContext, + ); + + const options: string[] = ['simulation']; + if (preferences.useSecurityAlerts) { + options.push('validation'); + } + + const scanAccount = { + type: interfaceContext.accountType, + address: fromAddress, + } as TronKeyringAccount; + + let { scan } = interfaceContext; + let { scanFetchStatus } = interfaceContext; + let freshTransactionRawData = transactionRawData; + + try { + freshTransactionRawData = + await this.#transactionExpirationRefresherService.ensureFreshRawData({ + scope, + rawData: transactionRawData, + }); + + scan = await this.#transactionScanService.scanTransaction({ + accountAddress: fromAddress, + transactionRawData: freshTransactionRawData, + origin, + scope, + options, + account: scanAccount, + }); + scanFetchStatus = scan ? FetchStatus.Fetched : FetchStatus.Error; + this.#logger.info('Successfully refreshed send confirmation scan'); + } catch (error) { + this.#logger.error('Error refreshing send confirmation scan:', error); + scan = null; + scanFetchStatus = FetchStatus.Error; + } + + const latestContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!latestContext) { + this.#logger.info( + 'Interface context no longer exists after scan, skipping update', + ); + return; + } + + const updatedContext: ConfirmTransactionRequestContext = { + ...latestContext, + scan, + scanFetchStatus, + transactionRawData: freshTransactionRawData, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + updatedContext, + ); + + this.#logger.info('Successfully refreshed send confirmation'); + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + } catch (error) { + this.#logger.warn({ error }, 'Could not refresh send confirmation'); + } + } + + /** + * Background job to refresh security scan for signTransaction confirmation dialogs. + */ + async refreshSignTransaction(): Promise { + this.#logger.info( + 'Background refresh triggered for signTransaction confirmation...', + ); + + const mapInterfaceNameToId = + (await this.#state.getKey( + 'mapInterfaceNameToId', + )) ?? {}; + + const confirmationInterfaceId = + mapInterfaceNameToId[CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME]; + + if (!confirmationInterfaceId) { + this.#logger.info( + 'No active signTransaction confirmation interface found', + ); + return; + } + + const interfaceContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!interfaceContext) { + this.#logger.info('Interface context no longer exists, skipping refresh'); + return; + } + + if ( + !interfaceContext.account?.address || + !interfaceContext.transaction || + !interfaceContext.scope + ) { + this.#logger.info('Context is missing required fields'); + return; + } + + const { preferences, scope, account, origin, transaction } = + interfaceContext; + + const shouldRefreshScan = + preferences.simulateOnChainActions || preferences.useSecurityAlerts; + + try { + const fetchingContext: ConfirmSignTransactionContext = { + ...interfaceContext, + scanFetchStatus: shouldRefreshScan + ? FetchStatus.Fetching + : interfaceContext.scanFetchStatus, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + fetchingContext, + ); + + let { scan, scanFetchStatus } = interfaceContext; + let freshTransactionHex = transaction.rawDataHex; + + if (shouldRefreshScan) { + const options: string[] = []; + if (preferences.simulateOnChainActions) { + options.push('simulation'); + } + if (preferences.useSecurityAlerts) { + options.push('validation'); + } + + try { + const freshTransaction = + await this.#transactionExpirationRefresherService.ensureFreshSerializedTransaction( + { + scope, + type: transaction.type, + rawDataHex: transaction.rawDataHex, + }, + ); + freshTransactionHex = freshTransaction.raw_data_hex; + + scan = await this.#transactionScanService.scanTransaction({ + accountAddress: account.address, + transactionRawData: freshTransaction.raw_data, + origin, + scope, + options, + account, + }); + scanFetchStatus = scan ? FetchStatus.Fetched : FetchStatus.Error; + this.#logger.info('Successfully refreshed signTransaction scan'); + } catch (error) { + this.#logger.error('Error refreshing signTransaction scan:', error); + scan = null; + scanFetchStatus = FetchStatus.Error; + } + } + + const latestContext = + await this.#snapClient.getInterfaceContext( + confirmationInterfaceId, + ); + + if (!latestContext) { + this.#logger.info( + 'Interface context no longer exists after scan, skipping update', + ); + return; + } + + const updatedContext: ConfirmSignTransactionContext = { + ...latestContext, + scan, + scanFetchStatus, + transaction: { + ...latestContext.transaction, + rawDataHex: freshTransactionHex, + }, + }; + + await this.#snapClient.updateInterface( + confirmationInterfaceId, + , + updatedContext, + ); + + this.#logger.info('Successfully refreshed signTransaction confirmation'); + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshSignTransaction, + duration: 'PT20S', + }); + } catch (error) { + this.#logger.warn({ error }, 'Could not refresh signTransaction'); + } + } + + /** + * Background job to track a transaction's confirmation status. + * Syncs accounts on first check to fetch transaction with status from network. + * Continues polling until confirmed, then syncs again to update status. + * Implements automatic retry logic with exponential backoff limits. + * + * @param params - Transaction tracking parameters + * @param params.txId - The transaction ID to track + * @param params.scope - The network scope (e.g., 'mainnet', 'shasta') + * @param params.accountIds - Account IDs to sync after confirmation (first account is always the sender) + * @param params.attempt - Current attempt number (for retry logic) + */ + async trackTransaction({ + txId, + scope, + accountIds, + attempt = 0, + }: { + txId: string; + scope: Network; + accountIds: string[]; + attempt: number; + }): Promise { + const maxAttempts = 15; // Maximum number of polling attempts + const pollingInterval = 'PT1S'; // Poll every 1 second + + this.#logger.info( + `[Attempt ${attempt + 1} of ${maxAttempts}] Tracking transaction ${txId} on ${scope}...`, + ); + + // Check if we've exceeded maximum attempts + if (attempt >= maxAttempts) { + this.#logger.warn( + { txId, scope, attempts: maxAttempts }, + 'Transaction tracking timeout - syncing accounts', + ); + + // Fallback: sync accounts anyway to update final status + const accounts = await this.#accountsService.findByIds(accountIds); + if (accounts.length > 0) { + await this.#accountsService.synchronize(accounts); + } + return; + } + + try { + // Fetch transaction info from Full Node API + // Note: This endpoint only returns data for confirmed transactions + const txInfo = await this.#tronHttpClient.getTransactionInfoById( + scope, + txId, + ); + + // If transaction not found yet (not confirmed), schedule next check + if (!txInfo) { + this.#logger.info( + { txId, attempt }, + 'Transaction not confirmed yet, scheduling next check...', + ); + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds, + attempt: attempt + 1, + }, + duration: pollingInterval, + }); + return; + } + + // Transaction found! This means it's confirmed on-chain + this.#logger.log( + { txId, blockNumber: txInfo.blockNumber, scope }, + '✅ Transaction confirmed on-chain', + ); + + // Get the sender account to determine account type + const accounts = await this.#accountsService.findByIds(accountIds); + const senderAccount = accounts[0]; + + if (!senderAccount) { + this.#logger.error({ txId }, 'Sender account not found'); + return; + } + + // Synchronize accounts to get the full transaction details and update state + // Scheduled in the background to avoid blocking the main thread + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeSelectedAccounts, + duration: 'PT1S', + }); + + // Track Transaction Finalized event now that transaction is confirmed + await this.#snapClient.trackTransactionFinalized({ + origin: 'MetaMask', + accountType: senderAccount.type, + chainIdCaip: scope, + }); + } catch (error) { + this.#logger.error( + { error, txId, scope, attempt }, + 'Error tracking transaction', + ); + + // On error, retry with next attempt (unless we've hit max attempts) + if (attempt < maxAttempts - 1) { + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds, + attempt: attempt + 1, + }, + duration: pollingInterval, + }); + } else { + // Max attempts reached - fallback to sync + this.#logger.warn( + { txId, scope }, + 'Max tracking attempts reached with errors - falling back to account sync', + ); + const accounts = await this.#accountsService.findByIds(accountIds); + if (accounts.length > 0) { + await this.#accountsService.synchronize(accounts); + } + } + } + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring-types.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring-types.ts new file mode 100644 index 00000000..beab480a --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring-types.ts @@ -0,0 +1,29 @@ +/** + * Enum for Tron Multichain API methods that are handled via submitRequest + */ +export enum TronMultichainMethod { + SignMessage = 'signMessage', + SignTransaction = 'signTransaction', +} + +/** + * Error codes for Tron Multichain API + */ +export const TronMultichainErrors = { + InvalidParams: { + code: 4001, + message: 'Invalid method parameters', + }, + InvalidTransaction: { + code: 4002, + message: 'Invalid transaction format', + }, + UserRejected: { + code: 4100, + message: 'User rejected the request', + }, + UnknownError: { + code: 5000, + message: 'Unknown error with request', + }, +} as const; diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.test.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.test.ts new file mode 100644 index 00000000..3e09a4a0 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.test.ts @@ -0,0 +1,716 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import { + InvalidParamsError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; +import { bytesToBase64, bytesToHex, stringToBytes } from '@metamask/utils'; + +import type { SnapClient } from '../clients/snap/SnapClient'; +import { Network } from '../constants'; +import { KeyringHandler } from './keyring'; +import { TronMultichainMethod } from './keyring-types'; +import type { TronKeyringAccount } from '../entities/keyring-account'; +import type { AccountsService } from '../services/accounts/AccountsService'; +import type { AssetsService } from '../services/assets/AssetsService'; +import type { ConfirmationHandler } from '../services/confirmation/ConfirmationHandler'; +import type { TransactionExpirationRefresherService } from '../services/transaction-expiration-refresher/TransactionExpirationRefresherService'; +import type { TransactionsService } from '../services/transactions/TransactionsService'; +import type { WalletService } from '../services/wallet/WalletService'; +import { mockLogger } from '../utils/mockLogger'; + +/** + * Helper function to convert string to base64. + * + * @param str - The string to convert. + * @returns Base64 encoded string. + */ +function toBase64(str: string): string { + return bytesToBase64(stringToBytes(str)); +} + +/** + * Helper function to convert string to hex. + * + * @param str - The string to convert. + * @returns Hex encoded string (without 0x prefix). + */ +function toHex(str: string): string { + return bytesToHex(stringToBytes(str)).slice(2); +} + +describe('KeyringHandler', () => { + const mockAccount: TronKeyringAccount = { + id: '123e4567-e89b-42d3-a456-426614174000', + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + options: {}, + methods: [ + TronMultichainMethod.SignMessage, + TronMultichainMethod.SignTransaction, + ], + type: 'tron:eoa', + scopes: [Network.Mainnet, Network.Shasta], + entropySource: 'entropy-source-1' as any, + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + let keyringHandler: KeyringHandler; + let mockSnapClient: jest.Mocked; + let mockAccountsService: jest.Mocked; + let mockAssetsService: jest.Mocked; + let mockTransactionsService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockConfirmationHandler: jest.Mocked; + let mockTransactionExpirationRefresherService: jest.Mocked< + Pick< + TransactionExpirationRefresherService, + 'ensureFreshSerializedTransaction' + > + >; + + beforeEach(() => { + mockSnapClient = { + scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), + trackError: jest.fn(), + } as any; + mockAccountsService = { + findById: jest.fn().mockResolvedValue(mockAccount), + findByIdOrThrow: jest.fn().mockResolvedValue(mockAccount), + deriveAccount: jest.fn(), + create: jest.fn(), + getAll: jest.fn().mockResolvedValue([mockAccount]), + } as any; + mockAssetsService = {} as any; + mockTransactionsService = { + checkAddressActivity: jest.fn(), + } as any; + mockWalletService = { + handleKeyringRequest: jest + .fn() + .mockResolvedValue({ signature: '0xsignature123' }), + } as any; + mockConfirmationHandler = { + handleKeyringRequest: jest.fn().mockResolvedValue(true), + } as any; + mockTransactionExpirationRefresherService = { + ensureFreshSerializedTransaction: jest.fn( + async ({ rawDataHex }) => + ({ + txID: 'test-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: {}, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: rawDataHex, + }) as any, + ), + }; + + keyringHandler = new KeyringHandler({ + logger: mockLogger, + snapClient: mockSnapClient, + accountsService: mockAccountsService, + assetsService: mockAssetsService, + transactionsService: mockTransactionsService, + walletService: mockWalletService, + confirmationHandler: mockConfirmationHandler, + transactionExpirationRefresherService: + mockTransactionExpirationRefresherService as unknown as TransactionExpirationRefresherService, + }); + }); + + describe('submitRequest', () => { + describe('signMessage', () => { + it('successfully signs a message', async () => { + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000001', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello World'), + }, + }, + }; + + const result = await keyringHandler.submitRequest(request); + + expect(result).toStrictEqual({ + pending: false, + result: { signature: '0xsignature123' }, + }); + expect(mockAccountsService.findById).toHaveBeenCalledWith( + mockAccount.id, + ); + expect( + mockConfirmationHandler.handleKeyringRequest, + ).toHaveBeenCalledWith({ + request, + account: mockAccount, + }); + expect(mockWalletService.handleKeyringRequest).toHaveBeenCalledWith({ + account: mockAccount, + scope: Network.Mainnet, + method: TronMultichainMethod.SignMessage, + params: request.request.params, + }); + expect( + mockTransactionExpirationRefresherService.ensureFreshSerializedTransaction, + ).not.toHaveBeenCalled(); + }); + + it('throws error if user rejects the request', async () => { + mockConfirmationHandler.handleKeyringRequest.mockResolvedValue(false); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000002', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + await expect(keyringHandler.submitRequest(request)).rejects.toThrow( + UserRejectedRequestError, + ); + expect(mockWalletService.handleKeyringRequest).not.toHaveBeenCalled(); + }); + + it('throws error if account not found', async () => { + mockAccountsService.findById.mockResolvedValue(null); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000003', + origin: 'https://test-origin.com', + account: '123e4567-e89b-42d3-a456-426614174999', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + await expect(keyringHandler.submitRequest(request)).rejects.toThrow( + 'not found', + ); + }); + }); + + describe('signTransaction', () => { + it('refreshes transaction metadata before confirming and signing a transaction', async () => { + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000004', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }, + }; + const transactionParams = request.request.params as { + transaction: { + rawDataHex: string; + type: string; + }; + }; + mockTransactionExpirationRefresherService.ensureFreshSerializedTransaction.mockResolvedValue( + { + txID: 'fresh-tx-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: {}, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: 'fresh-raw-data-hex', + } as any, + ); + + const result = await keyringHandler.submitRequest(request); + + expect(result).toStrictEqual({ + pending: false, + result: { signature: '0xsignature123' }, + }); + expect( + mockTransactionExpirationRefresherService.ensureFreshSerializedTransaction, + ).toHaveBeenCalledWith({ + scope: Network.Mainnet, + type: transactionParams.transaction.type, + rawDataHex: transactionParams.transaction.rawDataHex, + }); + expect( + mockConfirmationHandler.handleKeyringRequest, + ).toHaveBeenCalledWith({ + request: expect.objectContaining({ + request: expect.objectContaining({ + params: expect.objectContaining({ + transaction: expect.objectContaining({ + rawDataHex: 'fresh-raw-data-hex', + }), + }), + }), + }), + account: mockAccount, + }); + expect(mockWalletService.handleKeyringRequest).toHaveBeenCalledWith({ + account: mockAccount, + scope: Network.Mainnet, + method: TronMultichainMethod.SignTransaction, + params: expect.objectContaining({ + transaction: expect.objectContaining({ + rawDataHex: 'fresh-raw-data-hex', + }), + }), + }); + }); + + it('throws error if user rejects transaction signing', async () => { + mockConfirmationHandler.handleKeyringRequest.mockResolvedValue(false); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000005', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }, + }; + + await expect(keyringHandler.submitRequest(request)).rejects.toThrow( + UserRejectedRequestError, + ); + }); + }); + + describe('validation', () => { + it('throws error for invalid scope', async () => { + const invalidAccount = { + ...mockAccount, + scopes: [Network.Mainnet], + }; + mockAccountsService.findById.mockResolvedValue(invalidAccount); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000006', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Shasta, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + await expect(keyringHandler.submitRequest(request)).rejects.toThrow( + 'is not allowed for this account', + ); + }); + + it('throws error for unsupported method', async () => { + const invalidAccount = { + ...mockAccount, + methods: [TronMultichainMethod.SignMessage], + }; + mockAccountsService.findById.mockResolvedValue(invalidAccount); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000007', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }, + }; + + await expect(keyringHandler.submitRequest(request)).rejects.toThrow( + 'is not allowed for this account', + ); + }); + + it('throws error for malformed request structure', async () => { + const invalidRequest = { + id: '00000000-0000-4000-8000-000000000008', + origin: 'https://test-origin.com', + account: 'invalid-uuid', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: {}, + }, + } as any; + + await expect( + keyringHandler.submitRequest(invalidRequest), + ).rejects.toThrow('UuidV4'); + }); + + it('throws error for missing params', async () => { + const request = { + id: '00000000-0000-4000-8000-000000000009', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + // Missing params + }, + } as any; + + await expect(keyringHandler.submitRequest(request)).rejects.toThrow( + 'satisfy a union', + ); + }); + }); + + describe('error handling', () => { + it('propagates wallet service errors', async () => { + mockWalletService.handleKeyringRequest.mockRejectedValue( + new Error('Signing failed'), + ); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000010', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + await expect(keyringHandler.submitRequest(request)).rejects.toThrow( + 'Signing failed', + ); + }); + + it('propagates confirmation handler errors', async () => { + mockConfirmationHandler.handleKeyringRequest.mockRejectedValue( + new Error('Confirmation failed'), + ); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000011', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + await expect(keyringHandler.submitRequest(request)).rejects.toThrow( + 'Confirmation failed', + ); + }); + }); + + describe('multiple accounts', () => { + it('handles different accounts correctly', async () => { + const account2: TronKeyringAccount = { + ...mockAccount, + id: '987e6543-e89b-42d3-a456-426614174999', + address: 'TGehVcNhud84JDCGrNHKVz9jEAVKUpbuiv', + }; + + mockAccountsService.findById + .mockResolvedValueOnce(mockAccount) + .mockResolvedValueOnce(account2); + + const request1: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000012', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: mockAccount.address, + message: toBase64('Message 1'), + }, + }, + }; + + const request2: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000013', + origin: 'https://test-origin.com', + account: account2.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: account2.address, + message: toBase64('Message 2'), + }, + }, + }; + + await keyringHandler.submitRequest(request1); + await keyringHandler.submitRequest(request2); + + expect(mockAccountsService.findById).toHaveBeenCalledTimes(2); + expect(mockWalletService.handleKeyringRequest).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ account: mockAccount }), + ); + expect(mockWalletService.handleKeyringRequest).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ account: account2 }), + ); + }); + }); + + describe('multiple networks', () => { + it('handles different networks correctly', async () => { + const mainnetRequest: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000014', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Mainnet'), + }, + }, + }; + + const shastaRequest: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000015', + origin: 'https://test-origin.com', + account: mockAccount.id, + scope: Network.Shasta, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Shasta'), + }, + }, + }; + + await keyringHandler.submitRequest(mainnetRequest); + await keyringHandler.submitRequest(shastaRequest); + + expect(mockWalletService.handleKeyringRequest).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ scope: Network.Mainnet }), + ); + expect(mockWalletService.handleKeyringRequest).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ scope: Network.Shasta }), + ); + }); + }); + }); + + describe('discoverAccounts', () => { + const mockDerivedAccount: TronKeyringAccount = { + id: '123e4567-e89b-42d3-a456-426614174001', + address: 'TDerivedAddress12345678901234567', + options: {}, + methods: [ + TronMultichainMethod.SignMessage, + TronMultichainMethod.SignTransaction, + ], + type: 'tron:eoa', + scopes: [Network.Mainnet, Network.Shasta], + entropySource: 'test-entropy-source' as any, + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + beforeEach(() => { + jest + .spyOn(mockAccountsService, 'deriveAccount') + .mockImplementation() + .mockResolvedValue(mockDerivedAccount); + jest + .spyOn(mockTransactionsService, 'checkAddressActivity') + .mockImplementation(); + }); + + it('returns empty array if there is no activity on any of the scopes', async () => { + mockTransactionsService.checkAddressActivity + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + const result = await keyringHandler.discoverAccounts?.( + [Network.Mainnet, Network.Shasta], + 'test-entropy-source', + 0, + ); + + expect(result).toStrictEqual([]); + expect(mockAccountsService.deriveAccount).toHaveBeenCalledWith({ + entropySource: 'test-entropy-source', + index: 0, + }); + }); + + it('returns discovered accounts when there is activity on any scope', async () => { + mockTransactionsService.checkAddressActivity + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const result = await keyringHandler.discoverAccounts?.( + [Network.Mainnet, Network.Shasta], + 'test-entropy-source', + 3, + ); + + expect(result).toStrictEqual([ + { + type: 'bip44', + scopes: [Network.Mainnet, Network.Shasta], + derivationPath: mockDerivedAccount.derivationPath, + }, + ]); + }); + + it('throws error if there is an error fetching transactions', async () => { + mockTransactionsService.checkAddressActivity.mockRejectedValue( + new Error('Network error'), + ); + + await expect( + keyringHandler.discoverAccounts?.([Network.Mainnet], 'test', 0), + ).rejects.toThrow('Network error'); + }); + + it('throws error if no scopes are provided', async () => { + await expect( + keyringHandler.discoverAccounts?.([], 'test', 0), + ).rejects.toThrow('Expected a nonempty array but received an empty one'); + expect( + mockTransactionsService.checkAddressActivity, + ).not.toHaveBeenCalled(); + }); + + it('throws error if scope is not a valid Tron network', async () => { + await expect( + keyringHandler.discoverAccounts?.( + ['invalid:network' as Network], + 'test', + 0, + ), + ).rejects.toThrow(/Expected one of/u); + expect( + mockTransactionsService.checkAddressActivity, + ).not.toHaveBeenCalled(); + }); + + it('throws error if groupIndex is negative', async () => { + await expect( + keyringHandler.discoverAccounts?.([Network.Mainnet], 'test', -1), + ).rejects.toThrow( + 'Expected a number greater than or equal to 0 but received `-1`', + ); + expect( + mockTransactionsService.checkAddressActivity, + ).not.toHaveBeenCalled(); + }); + }); + + describe('setSelectedAccounts', () => { + const NON_EXISTENT_ACCOUNT_ID = '123e4567-e89b-42d3-a456-426614174999'; + + it('schedules a background sync for known accounts', async () => { + await keyringHandler.setSelectedAccounts([mockAccount.id]); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: 'onSynchronizeSelectedAccounts', + params: { accountIds: [mockAccount.id] }, + duration: 'PT1S', + }); + }); + + it('rejects if an account id is not a valid UUID', async () => { + await expect( + keyringHandler.setSelectedAccounts([mockAccount.id, 'not-a-uuid']), + ).rejects.toThrow(InvalidParamsError); + + expect(mockSnapClient.scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('rejects if an account id is not part of existing accounts', async () => { + await expect( + keyringHandler.setSelectedAccounts([ + mockAccount.id, + NON_EXISTENT_ACCOUNT_ID, + ]), + ).rejects.toThrow(InvalidParamsError); + + expect(mockSnapClient.scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + }); + + describe('listAccounts', () => { + it('fails with cause', async () => { + const causeError = new Error('Account error'); + + mockAccountsService.getAll.mockRejectedValue(causeError); + + await expect(keyringHandler.listAccounts()).rejects.toMatchObject({ + message: 'Error listing accounts', + cause: causeError, + }); + }); + }); + + describe('createAccount', () => { + it('fails with cause', async () => { + const causeError = new Error('Account error'); + + mockAccountsService.create.mockRejectedValue(causeError); + + await expect(keyringHandler.createAccount()).rejects.toMatchObject({ + message: `Error creating account: ${causeError.message}`, + cause: causeError, + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/handlers/keyring.ts b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts new file mode 100644 index 00000000..6a20f65f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/keyring.ts @@ -0,0 +1,548 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + KeyringEvent, + ListAccountAssetsResponseStruct, + type Balance, + type DiscoveredAccount, + type EntropySourceId, + type Keyring, + type KeyringAccount, + type KeyringRequest, + type KeyringResponse, + type Pagination, + type ResolvedAccountAddress, + type Transaction, +} from '@metamask/keyring-api'; +import { + emitSnapKeyringEvent, + handleKeyringRequest, +} from '@metamask/keyring-snap-sdk'; +import { + InvalidParamsError, + SnapError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { array, assert } from '@metamask/superstruct'; +import type { + CaipAssetType, + CaipAssetTypeOrId, + CaipChainId, +} from '@metamask/utils'; +import { sortBy } from 'lodash'; + +import type { SnapClient } from '../clients/snap/SnapClient'; +import { ESSENTIAL_ASSETS, type Network } from '../constants'; +import { BackgroundEventMethod } from './cronjob'; +import { TronMultichainMethod } from './keyring-types'; +import { + asStrictKeyringAccount, + type TronKeyringAccount, +} from '../entities/keyring-account'; +import type { AccountsService } from '../services/accounts/AccountsService'; +import type { CreateAccountOptions } from '../services/accounts/types'; +import type { AssetsService } from '../services/assets/AssetsService'; +import type { ConfirmationHandler } from '../services/confirmation/ConfirmationHandler'; +import type { TransactionExpirationRefresherService } from '../services/transaction-expiration-refresher/TransactionExpirationRefresherService'; +import type { TransactionsService } from '../services/transactions/TransactionsService'; +import type { WalletService } from '../services/wallet/WalletService'; +import { withCatchAndThrowSnapError } from '../utils/errors'; +import { createPrefixedLogger, type ILogger } from '../utils/logger'; +import { + CreateAccountOptionsStruct, + DeleteAccountStruct, + DiscoverAccountsStruct, + GetAccounBalancesResponseStruct, + GetAccountBalancesStruct, + GetAccountStruct, + ListAccountAssetsStruct, + ListAccountTransactionsStruct, + SignTransactionRequestStruct, + TronKeyringRequestStruct, + type TronWalletKeyringRequest, + UuidStruct, +} from '../validation/structs'; +import { + validateOrigin, + validateRequest, + validateResponse, +} from '../validation/validators'; + +export class KeyringHandler implements Keyring { + readonly #logger: ILogger; + + readonly #snapClient: SnapClient; + + readonly #accountsService: AccountsService; + + readonly #assetsService: AssetsService; + + readonly #transactionsService: TransactionsService; + + readonly #walletService: WalletService; + + readonly #confirmationHandler: ConfirmationHandler; + + readonly #transactionExpirationRefresherService: TransactionExpirationRefresherService; + + constructor({ + logger, + snapClient, + accountsService, + assetsService, + transactionsService, + walletService, + confirmationHandler, + transactionExpirationRefresherService, + }: { + logger: ILogger; + snapClient: SnapClient; + accountsService: AccountsService; + assetsService: AssetsService; + transactionsService: TransactionsService; + walletService: WalletService; + confirmationHandler: ConfirmationHandler; + transactionExpirationRefresherService: TransactionExpirationRefresherService; + }) { + this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); + this.#snapClient = snapClient; + this.#accountsService = accountsService; + this.#assetsService = assetsService; + this.#transactionsService = transactionsService; + this.#walletService = walletService; + this.#confirmationHandler = confirmationHandler; + this.#transactionExpirationRefresherService = + transactionExpirationRefresherService; + } + + async handle(origin: string, request: JsonRpcRequest): Promise { + validateOrigin(origin, request.method); + + const result = + (await withCatchAndThrowSnapError(async () => + handleKeyringRequest(this, request), + )) ?? null; + + return result; + } + + async #listAccounts(): Promise { + try { + const keyringAccounts = await this.#accountsService.getAll(); + + return sortBy(keyringAccounts, ['entropySource', 'index']); + } catch (error) { + this.#logger.error({ error }, 'Error listing accounts'); + throw new Error('Error listing accounts', { cause: error }); + } + } + + async listAccounts(): Promise { + return (await this.#listAccounts()).map(asStrictKeyringAccount); + } + + async #getAccount( + accountId: string, + ): Promise { + try { + const account = + (await this.#accountsService.findById(accountId)) ?? undefined; + + return account; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error getting account'); + throw new SnapError(error); + } + } + + async getAccount(accountId: string): Promise { + validateRequest({ accountId }, GetAccountStruct); + + const account = await this.#getAccount(accountId); + + return account ? asStrictKeyringAccount(account) : undefined; + } + + async #getAccountOrThrow(accountId: string): Promise { + const account = await this.#getAccount(accountId); + + if (!account) { + throw new Error(`Account "${accountId}" not found`); + } + + return account; + } + + async createAccount(options?: CreateAccountOptions): Promise { + validateRequest(options, CreateAccountOptionsStruct); + + try { + return await this.#accountsService.create(options); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error creating account'); + throw new Error(`Error creating account: ${error.message}`, { + cause: error, + }); + } + } + + async listAccountAssets(accountId: string): Promise { + try { + validateRequest({ accountId }, ListAccountAssetsStruct); + + await this.#getAccountOrThrow(accountId); + + this.#logger.info('Listing account assets', { accountId }); + + const assetEntities = + await this.#assetsService.getByKeyringAccountId(accountId); + const result = assetEntities + .filter( + (asset) => + ESSENTIAL_ASSETS.includes(asset.assetType) || + Number(asset.rawAmount) > 0, + ) + .map((asset) => asset.assetType); + + this.#logger.info('Account assets', { accountId, result }); + + validateResponse(result, ListAccountAssetsResponseStruct); + return result; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error listing account assets'); + throw error; + } + } + + /** + * Fetch transactions from the Snap's state. + * + * @param accountId - The id of the account. + * @param pagination - The pagination options. + * @param pagination.limit - The limit of the transactions to fetch. + * @param pagination.next - The next signature to fetch from. + * @returns The transactions for the given account. + */ + async listAccountTransactions( + accountId: string, + pagination: Pagination, + ): Promise<{ + data: Transaction[]; + next: string | null; + }> { + try { + validateRequest({ accountId, pagination }, ListAccountTransactionsStruct); + + this.#logger.info('Listing account transactions...'); + const { limit, next } = pagination; + + const keyringAccount = await this.#getAccount(accountId); + + if (!keyringAccount) { + throw new Error('Account not found'); + } + + const transactions = await this.#transactionsService.findByAccounts([ + keyringAccount, + ]); + + // Find the starting index based on the 'next' signature + const startIndex = next + ? transactions.findIndex((tx) => tx.id === next) + : 0; + + // Get transactions from startIndex to startIndex + limit + const accountTransactions = transactions.slice( + startIndex, + startIndex + limit, + ); + + // Determine the next signature for pagination + const hasMore = startIndex + pagination.limit < transactions.length; + const nextSignature = hasMore + ? (transactions[startIndex + pagination.limit]?.id ?? null) + : null; + + return { + data: accountTransactions, + next: nextSignature, + }; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error listing account transactions'); + throw error; + } + } + + async discoverAccounts?( + scopes: CaipChainId[], + entropySource: EntropySourceId, + groupIndex: number, + ): Promise { + try { + validateRequest( + { scopes, entropySource, groupIndex }, + DiscoverAccountsStruct, + ); + + const derivedAccount = await this.#accountsService.deriveAccount({ + entropySource, + index: groupIndex, + }); + + const activityChecksPromises = []; + + for (const scope of scopes) { + activityChecksPromises.push( + this.#transactionsService.checkAddressActivity( + scope as Network, + derivedAccount.address, + ), + ); + } + + const activityOnScopes = await Promise.all(activityChecksPromises); + + const hasActivity = activityOnScopes.some((active) => active); + + if (!hasActivity) { + return []; + } + + return [ + { + type: 'bip44', + scopes, + derivationPath: derivedAccount.derivationPath, + }, + ]; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error discovering accounts'); + throw error; + } + } + + async getAccountBalances( + accountId: string, + assets: CaipAssetType[], + ): Promise> { + try { + validateRequest({ accountId, assets }, GetAccountBalancesStruct); + + this.#logger.info('Getting account balances', { accountId, assets }); + + await this.#getAccountOrThrow(accountId); + + const assetsList = + await this.#assetsService.getByKeyringAccountId(accountId); + + const assetsToUse = assetsList + .filter((asset) => assets.includes(asset.assetType)) + // Remove token assets with zero balance + .filter( + (asset) => + ESSENTIAL_ASSETS.includes(asset.assetType) || + Number(asset.rawAmount) > 0, + ); + + const result = assetsToUse.reduce>( + (acc, asset) => { + acc[asset.assetType] = { + unit: asset.symbol, + amount: asset.uiAmount, + }; + return acc; + }, + {}, + ); + + this.#logger.info('Account balances', { accountId, result }); + + validateResponse(result, GetAccounBalancesResponseStruct); + return result; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error getting account balances'); + throw error; + } + } + + /** + * Resolves an account address from a request. + * Routes to WalletService for address resolution and validation. + * + * @param scope - The CAIP-2 chain ID. + * @param request - The JSON-RPC request containing the address parameter. + * @returns The resolved account address in CAIP-10 format, or null if resolution fails. + */ + async resolveAccountAddress( + scope: CaipChainId, + request: JsonRpcRequest, + ): Promise { + this.#logger.info('Resolving account address', { scope, request }); + + // Get all keyring accounts + const keyringAccounts = await this.#listAccounts(); + + // Resolve the address using the wallet service + const caip10Address = await this.#walletService.resolveAccountAddress( + keyringAccounts, + scope as Network, + request, + ); + + return caip10Address; + } + + async filterAccountChains(id: string, chains: string[]): Promise { + throw new Error('Method not implemented.'); + } + + async updateAccount(account: KeyringAccount): Promise { + throw new Error('Method not implemented.'); + } + + async deleteAccount(accountId: string): Promise { + try { + validateRequest({ accountId }, DeleteAccountStruct); + + const account = await this.#getAccountOrThrow(accountId); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountDeleted, { + id: account.id, + }); + + await this.#accountsService.delete(accountId); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error deleting account'); + throw error; + } + } + + async submitRequest(request: KeyringRequest): Promise { + return { pending: false, result: await this.#handleSubmitRequest(request) }; + } + + async #prepareRequestForConfirmation( + request: TronWalletKeyringRequest, + ): Promise { + if (request.request.method !== TronMultichainMethod.SignTransaction) { + return request; + } + + assert(request.request.params, SignTransactionRequestStruct); + + const { + scope, + request: { + params: { + transaction: { rawDataHex, type }, + }, + }, + } = request; + + const freshTransaction = + await this.#transactionExpirationRefresherService.ensureFreshSerializedTransaction( + { + scope, + type, + rawDataHex, + }, + ); + + return { + ...request, + request: { + ...request.request, + params: { + ...request.request.params, + transaction: { + ...request.request.params.transaction, + rawDataHex: freshTransaction.raw_data_hex, + }, + }, + }, + }; + } + + async #handleSubmitRequest(request: KeyringRequest): Promise { + assert(request, TronKeyringRequestStruct); + + this.#logger.log('Handling submitRequest', { + method: request.request.method, + }); + + const { + request: { method, params = {} }, + scope, + account: accountId, + } = request; + + const account = await this.#getAccountOrThrow(accountId); + + if (scope && !account.scopes.includes(scope)) { + throw new Error(`Scope "${scope}" is not allowed for this account`); + } + + if (!account.methods.includes(method)) { + throw new Error(`Method "${method}" is not allowed for this account`); + } + + const requestToHandle = await this.#prepareRequestForConfirmation(request); + + const isConfirmed = await this.#confirmationHandler.handleKeyringRequest({ + request: requestToHandle, + account, + }); + + if (!isConfirmed) { + throw new UserRejectedRequestError() as unknown as Error; + } + + const result = await this.#walletService.handleKeyringRequest({ + account, + scope: requestToHandle.scope, + method: requestToHandle.request.method as TronMultichainMethod, + params: requestToHandle.request.params ?? params, + }); + + return result; + } + + /** + * Endpoint that the client can use to inform the snap that certain accounts are selected. + * + * @param accountIds - The IDs of the accounts to set as selected. + */ + async setSelectedAccounts(accountIds: string[]): Promise { + validateRequest(accountIds, array(UuidStruct)); + + const existingIds = new Set( + (await this.#listAccounts()).map((account) => account.id), + ); + if (!accountIds.every((id) => existingIds.has(id))) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw new InvalidParamsError( + 'Account IDs were not part of existing accounts.', + ); + } + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeSelectedAccounts, + params: { accountIds }, + duration: 'PT1S', + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/rpc/rpc.ts b/merged-packages/tron-wallet-snap/src/handlers/rpc/rpc.ts new file mode 100644 index 00000000..7211653c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/rpc/rpc.ts @@ -0,0 +1,27 @@ +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; + +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import { validateOrigin } from '../../validation/validators'; + +export class RpcHandler { + readonly #logger: ILogger; + + constructor({ logger }: { logger: ILogger }) { + this.#logger = createPrefixedLogger(logger, '[👋 RpcHandler]'); + } + + async handle(origin: string, request: JsonRpcRequest): Promise { + validateOrigin(origin, request.method); + + this.#logger.log('Handling RPC request', request); + + const { method } = request; + + switch (method) { + default: + throw new MethodNotFoundError() as Error; + } + } +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/rpc/types.ts b/merged-packages/tron-wallet-snap/src/handlers/rpc/types.ts new file mode 100644 index 00000000..fc88bc5e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/rpc/types.ts @@ -0,0 +1,13 @@ +export enum RpcRequestMethods { + OnStart = 'onStart', + OnInstall = 'onInstall', + OnUpdate = 'onUpdate', +} + +/** + * Methods specific to the test dapp, + * to allow specific flows for manual testing. + */ +export enum TestDappRpcRequestMethod { + ComputeFee = 'computeFee', +} diff --git a/merged-packages/tron-wallet-snap/src/handlers/userInput.ts b/merged-packages/tron-wallet-snap/src/handlers/userInput.ts new file mode 100644 index 00000000..56a88f3f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/handlers/userInput.ts @@ -0,0 +1,72 @@ +import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; + +import type { SnapClient } from '../clients/snap/SnapClient'; +import { createEventHandlers as createSignMessageEvents } from '../ui/confirmation/views/ConfirmSignMessage/events'; +import { createEventHandlers as createSignTransactionEvents } from '../ui/confirmation/views/ConfirmSignTransaction/events'; +import { createEventHandlers as createTransactionConfirmationEvents } from '../ui/confirmation/views/ConfirmTransactionRequest/events'; +import { withCatchAndThrowSnapError } from '../utils/errors'; +import { createPrefixedLogger, type ILogger } from '../utils/logger'; + +export class UserInputHandler { + readonly #logger: ILogger; + + readonly #snapClient: SnapClient; + + constructor({ + logger, + snapClient, + }: { + logger: ILogger; + snapClient: SnapClient; + }) { + this.#logger = createPrefixedLogger(logger, '[👵 LifecycleHandler]'); + this.#snapClient = snapClient; + } + + /** + * Handle user events requests. + * + * @param args - The request handler args as object. + * @param args.id - The interface id associated with the event. + * @param args.event - The event object. + * @param args.context - The context object. + * @returns A promise that resolves to a JSON object. + * @throws If the request method is not valid for this snap. + */ + async handle({ + id, + event, + context, + }: { + id: string; + event: UserInputEvent; + context: InterfaceContext | null; + }): Promise { + this.#logger.log('[👇 onUserInput]', id, event); + + if (!event.name) { + return; + } + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const uiEventHandlers: Record Promise> = { + ...createTransactionConfirmationEvents(this.#snapClient), + ...createSignMessageEvents(this.#snapClient), + ...createSignTransactionEvents(this.#snapClient), + }; + + /** + * Using the name of the event, route it to the correct handler + */ + const handler = uiEventHandlers[event.name]; + + if (!handler) { + return; + } + + await withCatchAndThrowSnapError(async () => + handler({ id, event, context }), + ); + } +} diff --git a/merged-packages/tron-wallet-snap/src/index.ts b/merged-packages/tron-wallet-snap/src/index.ts new file mode 100644 index 00000000..80fb5f84 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/index.ts @@ -0,0 +1,65 @@ +import type { + OnAssetHistoricalPriceHandler, + OnAssetsConversionHandler, + OnAssetsLookupHandler, + OnAssetsMarketDataHandler, + OnClientRequestHandler, + OnCronjobHandler, + OnKeyringRequestHandler, + OnRpcRequestHandler, + OnUserInputHandler, +} from '@metamask/snaps-sdk'; + +import { + assetsHandler, + clientRequestHandler, + cronHandler, + keyringHandler, + rpcHandler, + userInputHandler, +} from './context'; +import { withCatchAndThrowSnapError } from './utils/errors'; + +/** + * Register all handlers + */ + +export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( + args, +) => + withCatchAndThrowSnapError(async () => + assetsHandler.onAssetHistoricalPrice(args), + ); + +export const onAssetsConversion: OnAssetsConversionHandler = async (args) => + withCatchAndThrowSnapError(async () => + assetsHandler.onAssetsConversion(args), + ); + +export const onAssetsLookup: OnAssetsLookupHandler = async (args) => + withCatchAndThrowSnapError(async () => assetsHandler.onAssetsLookup(args)); + +export const onAssetsMarketData: OnAssetsMarketDataHandler = async (args) => + withCatchAndThrowSnapError(async () => + assetsHandler.onAssetsMarketData(args), + ); + +export const onClientRequest: OnClientRequestHandler = async ({ request }) => + withCatchAndThrowSnapError(async () => clientRequestHandler.handle(request)); + +export const onCronjob: OnCronjobHandler = async ({ request }) => + withCatchAndThrowSnapError(async () => cronHandler.handle(request)); + +export const onKeyringRequest: OnKeyringRequestHandler = async ({ + origin, + request, +}) => + withCatchAndThrowSnapError(async () => + keyringHandler.handle(origin, request), + ); + +export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => + withCatchAndThrowSnapError(async () => rpcHandler.handle(origin, request)); + +export const onUserInput: OnUserInputHandler = async (params) => + withCatchAndThrowSnapError(async () => userInputHandler.handle(params)); diff --git a/merged-packages/tron-wallet-snap/src/permissions.ts b/merged-packages/tron-wallet-snap/src/permissions.ts new file mode 100644 index 00000000..04476159 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/permissions.ts @@ -0,0 +1,50 @@ +import { KeyringRpcMethod } from '@metamask/keyring-api'; + +import { TestDappRpcRequestMethod } from './handlers/rpc/types'; + +// eslint-disable-next-line no-restricted-globals +const isDev = process.env.ENVIRONMENT !== 'production'; + +const prodOrigins = ['https://portfolio.metamask.io']; +const allowedOrigins = isDev ? ['http://localhost:3000'] : prodOrigins; + +const dappPermissions = isDev + ? new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.DiscoverAccounts, + KeyringRpcMethod.GetAccountBalances, + KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, + // Test dapp specific methods + TestDappRpcRequestMethod.ComputeFee, + ]) + : new Set([]); + +const metamaskPermissions = new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.DiscoverAccounts, + KeyringRpcMethod.GetAccountBalances, + KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, + KeyringRpcMethod.ResolveAccountAddress, + KeyringRpcMethod.SetSelectedAccounts, +]); + +const metamask = 'metamask'; + +export const originPermissions = new Map>([]); + +for (const origin of allowedOrigins) { + originPermissions.set(origin, dappPermissions); +} +originPermissions.set(metamask, metamaskPermissions); diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts new file mode 100644 index 00000000..aed17bf8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsRepository.ts @@ -0,0 +1,56 @@ +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +export class AccountsRepository { + readonly #storageKey = 'keyringAccounts'; + + readonly #state: IStateManager; + + constructor(state: IStateManager) { + this.#state = state; + } + + /** + * Returns all accounts from the state. + * + * @returns All accounts from the state. + */ + async getAll(): Promise { + const accounts = await this.#state.getKey< + UnencryptedStateValue['keyringAccounts'] + >(this.#storageKey); + + return Object.values(accounts ?? {}); + } + + async findById(id: string): Promise { + const accounts = await this.getAll(); + return accounts.find((account) => account.id === id) ?? null; + } + + async findByIds(ids: string[]): Promise { + const accounts = await this.getAll(); + return accounts.filter((account) => ids.includes(account.id)); + } + + async findByAddress(address: string): Promise { + const accounts = await this.getAll(); + + return accounts.find((account) => account.address === address) ?? null; + } + + async create(account: TronKeyringAccount): Promise { + await this.#state.setKey(`${this.#storageKey}.${account.id}`, account); + + return account; + } + + async delete(id: string): Promise { + await Promise.all([ + this.#state.deleteKey(`${this.#storageKey}.${id}`), + this.#state.deleteKey(`assets.${id}`), + this.#state.deleteKey(`transactions.${id}`), + ]); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts new file mode 100644 index 00000000..3bff4be2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts @@ -0,0 +1,862 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; +import { + emitSnapKeyringEvent, + getSelectedAccounts, +} from '@metamask/keyring-snap-sdk'; + +import type { AccountsRepository } from './AccountsRepository'; +import { AccountsService } from './AccountsService'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import { Network } from '../../constants'; +import type { NativeAsset } from '../../entities/assets'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import type { ILogger } from '../../utils/logger'; +import { mockLogger } from '../../utils/mockLogger'; +import type { AssetsService } from '../assets/AssetsService'; +import type { ConfigProvider } from '../config'; +import type { Config } from '../config/ConfigProvider'; +import type { TransactionsService } from '../transactions/TransactionsService'; + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), + getSelectedAccounts: jest.fn().mockResolvedValue([]), +})); + +const mockedEmitSnapKeyringEvent = emitSnapKeyringEvent as jest.MockedFunction< + typeof emitSnapKeyringEvent +>; +const mockedGetSelectedAccounts = getSelectedAccounts as jest.MockedFunction< + typeof getSelectedAccounts +>; + +/** + * Valid secp256k1 key pair (private key 1). + * Public key uncompressed format; yields a deterministic address via computeAddress. + */ +const TEST_KEY_PAIR = { + privateKey: + '0x0000000000000000000000000000000000000000000000000000000000000001', + publicKey: + '0x0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8', +}; + +const EMPTY_NETWORK_URLS: Record = { + [Network.Mainnet]: '', + [Network.Nile]: '', + [Network.Shasta]: '', +}; + +const MOCK_CONFIG: Config = { + environment: 'test', + networks: [], + activeNetworks: [], + priceApi: { + baseUrl: '', + chunkSize: 0, + cacheTtlsMilliseconds: { + fiatExchangeRates: 0, + spotPrices: 0, + historicalPrices: 0, + }, + }, + tokenApi: { baseUrl: '', chunkSize: 0 }, + staticApi: { baseUrl: '' }, + transactions: { storageLimit: 0 }, + securityAlertsApi: { baseUrl: '' }, + nftApi: { + baseUrl: '', + cacheTtlsMilliseconds: { listAddressSolanaNfts: 0, getNftMetadata: 0 }, + }, + trongridApi: { baseUrls: EMPTY_NETWORK_URLS }, + tronHttpApi: { baseUrls: EMPTY_NETWORK_URLS }, +}; + +type WithAccountsServiceCallback = (payload: { + accountsService: AccountsService; + mockAccountsRepository: jest.Mocked< + Pick< + AccountsRepository, + | 'getAll' + | 'findById' + | 'findByIds' + | 'findByAddress' + | 'create' + | 'delete' + > + >; + mockConfigProvider: jest.Mocked>; + mockLogger: ILogger; + mockAssetsService: jest.Mocked< + Pick + >; + mockSnapClient: jest.Mocked< + Pick + >; + mockTransactionsService: jest.Mocked< + Pick + >; +}) => void | Promise; + +/** + * Creates a fresh AccountsService with all mock dependencies and passes them + * to the test callback. Resets globals and mocks before each invocation. + * + * @param testFn - Callback that receives the service and mocks for testing. + */ +async function withAccountsService( + testFn: WithAccountsServiceCallback, +): Promise { + Object.defineProperty(globalThis, 'snap', { + value: { request: jest.fn() }, + writable: true, + configurable: true, + }); + + const mockAccountsRepository: jest.Mocked< + Pick< + AccountsRepository, + | 'getAll' + | 'findById' + | 'findByIds' + | 'findByAddress' + | 'create' + | 'delete' + > + > = { + getAll: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(null), + findByIds: jest.fn().mockResolvedValue([]), + findByAddress: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + }; + + const mockConfigProvider: jest.Mocked> = { + get: jest.fn().mockReturnValue(MOCK_CONFIG), + }; + + const mockSnapClient: jest.Mocked< + Pick + > = { + getBip32Entropy: jest.fn().mockResolvedValue(TEST_KEY_PAIR), + listEntropySources: jest + .fn() + .mockResolvedValue([{ id: 'test-entropy', primary: true }]), + }; + + const mockAssetsService: jest.Mocked< + Pick + > = { + fetchAssetsAndBalancesForAccount: jest.fn().mockResolvedValue([]), + saveMany: jest.fn().mockResolvedValue(undefined), + }; + + const mockTransactionsService: jest.Mocked< + Pick + > = { + fetchNewTransactionsForAccount: jest.fn().mockResolvedValue([]), + saveMany: jest.fn().mockResolvedValue(undefined), + }; + + const accountsService = new AccountsService({ + accountsRepository: mockAccountsRepository, + configProvider: mockConfigProvider, + logger: mockLogger, + assetsService: mockAssetsService, + snapClient: mockSnapClient, + transactionsService: mockTransactionsService, + } as unknown as ConstructorParameters[0]); + + await testFn({ + accountsService, + mockAccountsRepository, + mockConfigProvider, + mockLogger, + mockAssetsService, + mockSnapClient, + mockTransactionsService, + }); +} + +describe('AccountsService', () => { + describe('getDefaultDerivationPath', () => { + it('returns path for index 0', () => { + expect(AccountsService.getDefaultDerivationPath(0)).toBe( + "m/44'/195'/0'/0/0", + ); + }); + + it('returns path for index 5', () => { + expect(AccountsService.getDefaultDerivationPath(5)).toBe( + "m/44'/195'/0'/0/5", + ); + }); + }); + + describe('deriveAccount', () => { + it('returns TronKeyringAccount with correct structure for index 0', async () => { + await withAccountsService(async ({ accountsService, mockSnapClient }) => { + const result = await accountsService.deriveAccount({ + entropySource: 'test-entropy', + index: 0, + }); + + expect(result).toMatchObject({ + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + methods: ['signMessage', 'signTransaction'], + }); + expect(result.id).toBeDefined(); + expect(typeof result.id).toBe('string'); + expect(result.address).toBeDefined(); + expect(result.address.length).toBeGreaterThan(0); + expect(result.options.entropy).toMatchObject({ + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + groupIndex: 0, + }); + + expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledWith({ + entropySource: 'test-entropy', + path: ['m', "44'", "195'", "0'", '0', '0'], + curve: 'secp256k1', + }); + }); + }); + + it('returns correct derivation path for index 5', async () => { + await withAccountsService(async ({ accountsService, mockSnapClient }) => { + const result = await accountsService.deriveAccount({ + entropySource: 'test-entropy', + index: 5, + }); + + expect(result.derivationPath).toBe("m/44'/195'/0'/0/5"); + expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['m', "44'", "195'", "0'", '0', '5'], + }), + ); + }); + }); + }); + + describe('create', () => { + it('creates and persists a new account', async () => { + mockedEmitSnapKeyringEvent.mockResolvedValue(); + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + jest.spyOn(accountsService, 'deriveAccount').mockResolvedValue({ + id: 'test-uuid-123', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TTestAddress1234567890123456789', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropy: { + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + groupIndex: 0, + }, + exportable: true, + }, + methods: ['signMessage', 'signTransaction'], + }); + + const result = await accountsService.create({ + entropySource: 'test-entropy', + index: 0, + }); + + expect(result.id).toBe('test-uuid-123'); + expect(mockAccountsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ id: 'test-uuid-123' }), + ); + expect(mockedEmitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountCreated, + expect.objectContaining({ + account: expect.objectContaining({ id: 'test-uuid-123' }), + }), + ); + }, + ); + }); + + it('returns existing account when same derivation path exists', async () => { + const existingAccount: TronKeyringAccount = { + id: 'existing-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TExisting123456789012345678901', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: {}, + methods: ['signMessage', 'signTransaction'], + }; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.getAll.mockResolvedValue([existingAccount]); + + const result = await accountsService.create({ + entropySource: 'test-entropy', + index: 0, + }); + + expect(result.id).toBe('existing-id'); + expect(mockAccountsRepository.create).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalled(); + }, + ); + }); + + it('rolls back persisted account when event emission fails', async () => { + mockedEmitSnapKeyringEvent.mockRejectedValue( + new Error('Event emission failed'), + ); + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + jest.spyOn(accountsService, 'deriveAccount').mockResolvedValue({ + id: 'rollback-test-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TRollback12345678901234567890', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropy: { + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + groupIndex: 0, + }, + exportable: true, + }, + methods: ['signMessage', 'signTransaction'], + }); + + await expect( + accountsService.create({ + entropySource: 'test-entropy', + index: 0, + }), + ).rejects.toThrow('Event emission failed'); + + expect(mockAccountsRepository.create).toHaveBeenCalled(); + expect(mockAccountsRepository.delete).toHaveBeenCalledWith( + 'rollback-test-id', + ); + }, + ); + }); + + it('preserves the original error when rollback delete also fails', async () => { + mockedEmitSnapKeyringEvent.mockRejectedValue( + new Error('Event emission failed'), + ); + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.delete.mockRejectedValue( + new Error('Delete failed'), + ); + jest.spyOn(accountsService, 'deriveAccount').mockResolvedValue({ + id: 'rollback-fail-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TRollback12345678901234567890', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropy: { + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + groupIndex: 0, + }, + exportable: true, + }, + methods: ['signMessage', 'signTransaction'], + }); + + await expect( + accountsService.create({ + entropySource: 'test-entropy', + index: 0, + }), + ).rejects.toThrow('Event emission failed'); + + expect(mockAccountsRepository.delete).toHaveBeenCalledWith( + 'rollback-fail-id', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ accountId: 'rollback-fail-id' }), + 'Failed to rollback account creation', + ); + }, + ); + }); + + it('passes metamask options through to emit', async () => { + mockedEmitSnapKeyringEvent.mockResolvedValue(); + + await withAccountsService(async ({ accountsService }) => { + jest.spyOn(accountsService, 'deriveAccount').mockResolvedValue({ + id: 'meta-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TMeta1234567890123456789012', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: {}, + methods: ['signMessage', 'signTransaction'], + }); + + await accountsService.create({ + entropySource: 'test-entropy', + index: 0, + metamask: { correlationId: 'corr-123' }, + }); + + expect(mockedEmitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountCreated, + expect.objectContaining({ + metamask: { correlationId: 'corr-123' }, + }), + ); + }); + }); + }); + + describe('getAll', () => { + it('delegates to repository and returns result', async () => { + const accounts: TronKeyringAccount[] = [ + { + id: 'a1', + address: 'TAddr1', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }, + ]; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.getAll.mockResolvedValue(accounts); + + const result = await accountsService.getAll(); + + expect(result).toStrictEqual(accounts); + expect(mockAccountsRepository.getAll).toHaveBeenCalled(); + }, + ); + }); + }); + + describe('getAllSelected', () => { + it('returns only accounts whose IDs are in getSelectedAccounts', async () => { + const account1: TronKeyringAccount = { + id: 'selected-1', + address: 'TAddr1', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + const account2: TronKeyringAccount = { + id: 'not-selected', + address: 'TAddr2', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/1", + index: 1, + }; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.getAll.mockResolvedValue([account1, account2]); + mockedGetSelectedAccounts.mockResolvedValue(['selected-1']); + + const result = await accountsService.getAllSelected(); + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe('selected-1'); + }, + ); + }); + + it('returns empty when no accounts selected', async () => { + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.getAll.mockResolvedValue([]); + mockedGetSelectedAccounts.mockResolvedValue([]); + + const result = await accountsService.getAllSelected(); + + expect(result).toStrictEqual([]); + }, + ); + }); + }); + + describe('findById', () => { + it('delegates to repository', async () => { + const account: TronKeyringAccount = { + id: 'find-id', + address: 'TFind', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.findById.mockResolvedValue(account); + + const result = await accountsService.findById('find-id'); + + expect(result).toStrictEqual(account); + expect(mockAccountsRepository.findById).toHaveBeenCalledWith( + 'find-id', + ); + }, + ); + }); + }); + + describe('findByIdOrThrow', () => { + it('returns account when found', async () => { + const account: TronKeyringAccount = { + id: 'throw-found', + address: 'TFound', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.findById.mockResolvedValue(account); + + const result = await accountsService.findByIdOrThrow('throw-found'); + + expect(result).toStrictEqual(account); + }, + ); + }); + + it('throws when account not found', async () => { + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.findById.mockResolvedValue(null); + + await expect( + accountsService.findByIdOrThrow('missing-id'), + ).rejects.toThrow('Account with ID missing-id not found'); + }, + ); + }); + }); + + describe('findByIds', () => { + it('returns accounts from repository', async () => { + const accounts: TronKeyringAccount[] = [ + { + id: 'id1', + address: 'T1', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }, + ]; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.findByIds.mockResolvedValue(accounts); + + const result = await accountsService.findByIds(['id1']); + + expect(result).toStrictEqual(accounts); + expect(mockAccountsRepository.findByIds).toHaveBeenCalledWith([ + 'id1', + ]); + }, + ); + }); + + it('logs error when some accounts not found', async () => { + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.findByIds.mockResolvedValue([]); + + await accountsService.findByIds(['missing-1', 'missing-2']); + + expect(mockLogger.error).toHaveBeenCalledWith( + '[🔑 AccountsService]', + '[findByIds] Some accounts not found', + ); + }, + ); + }); + }); + + describe('findByAddress', () => { + it('delegates to repository', async () => { + const account: TronKeyringAccount = { + id: 'addr-id', + address: 'TByAddress123456789012345678', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.findByAddress.mockResolvedValue(account); + + const result = await accountsService.findByAddress( + 'TByAddress123456789012345678', + ); + + expect(result).toStrictEqual(account); + expect(mockAccountsRepository.findByAddress).toHaveBeenCalledWith( + 'TByAddress123456789012345678', + ); + }, + ); + }); + }); + + describe('delete', () => { + it('delegates to repository', async () => { + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + await accountsService.delete('delete-id'); + + expect(mockAccountsRepository.delete).toHaveBeenCalledWith( + 'delete-id', + ); + }, + ); + }); + }); + + describe('synchronizeAssets', () => { + it('calls fetch for each account and scope, then saveMany', async () => { + const account: TronKeyringAccount = { + id: 'sync-asset-id', + address: 'TSyncAsset12345678901234567', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + const mockAssets: NativeAsset[] = [ + { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: 'sync-asset-id', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + ]; + + await withAccountsService( + async ({ accountsService, mockConfigProvider, mockAssetsService }) => { + mockConfigProvider.get.mockReturnValue({ + ...MOCK_CONFIG, + activeNetworks: [Network.Mainnet, Network.Shasta], + }); + mockAssetsService.fetchAssetsAndBalancesForAccount.mockResolvedValue( + mockAssets, + ); + + await accountsService.synchronizeAssets([account]); + + expect( + mockAssetsService.fetchAssetsAndBalancesForAccount, + ).toHaveBeenCalledTimes(2); + expect( + mockAssetsService.fetchAssetsAndBalancesForAccount, + ).toHaveBeenCalledWith(Network.Mainnet, account); + expect( + mockAssetsService.fetchAssetsAndBalancesForAccount, + ).toHaveBeenCalledWith(Network.Shasta, account); + expect(mockAssetsService.saveMany).toHaveBeenCalledWith( + expect.arrayContaining(mockAssets), + ); + }, + ); + }); + + it('handles empty activeNetworks', async () => { + await withAccountsService( + async ({ accountsService, mockConfigProvider, mockAssetsService }) => { + mockConfigProvider.get.mockReturnValue(MOCK_CONFIG); + + const account: TronKeyringAccount = { + id: 'empty-id', + address: 'TEmpty12345678901234567890', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + await accountsService.synchronizeAssets([account]); + + expect( + mockAssetsService.fetchAssetsAndBalancesForAccount, + ).not.toHaveBeenCalled(); + expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); + }, + ); + }); + }); + + describe('synchronizeTransactions', () => { + it('calls fetch for each account and scope, then saveMany', async () => { + const account: TronKeyringAccount = { + id: 'sync-tx-id', + address: 'TSyncTx123456789012345678', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + const mockTransactions: Transaction[] = [ + { + id: 'tx-1', + type: 'send', + account: 'sync-tx-id', + chain: Network.Mainnet, + status: 'confirmed', + timestamp: 12345, + from: [], + to: [], + fees: [], + events: [], + }, + ]; + + await withAccountsService( + async ({ + accountsService, + mockConfigProvider, + mockTransactionsService, + }) => { + mockConfigProvider.get.mockReturnValue({ + ...MOCK_CONFIG, + activeNetworks: [Network.Mainnet], + }); + mockTransactionsService.fetchNewTransactionsForAccount.mockResolvedValue( + mockTransactions, + ); + + await accountsService.synchronizeTransactions([account]); + + expect( + mockTransactionsService.fetchNewTransactionsForAccount, + ).toHaveBeenCalledWith(Network.Mainnet, account); + expect(mockTransactionsService.saveMany).toHaveBeenCalledWith( + mockTransactions, + ); + }, + ); + }); + }); + + describe('synchronize', () => { + it('calls both synchronizeAssets and synchronizeTransactions', async () => { + const account: TronKeyringAccount = { + id: 'sync-id', + address: 'TSync12345678901234567890', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + await withAccountsService( + async ({ + accountsService, + mockConfigProvider, + mockAssetsService, + mockTransactionsService, + }) => { + mockConfigProvider.get.mockReturnValue({ + ...MOCK_CONFIG, + activeNetworks: [Network.Mainnet], + }); + + await accountsService.synchronize([account]); + + expect( + mockAssetsService.fetchAssetsAndBalancesForAccount, + ).toHaveBeenCalledWith(Network.Mainnet, account); + expect( + mockTransactionsService.fetchNewTransactionsForAccount, + ).toHaveBeenCalledWith(Network.Mainnet, account); + }, + ); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts new file mode 100644 index 00000000..b0b9943b --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -0,0 +1,394 @@ +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { KeyringEvent, TrxAccountType, TrxScope } from '@metamask/keyring-api'; +import { + emitSnapKeyringEvent, + getSelectedAccounts, +} from '@metamask/keyring-snap-sdk'; +import type { Json } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import { hexToBytes } from '@metamask/utils'; +import { computeAddress } from 'ethers'; +import { TronWeb } from 'tronweb'; + +import type { AccountsRepository } from './AccountsRepository'; +import type { CreateAccountOptions } from './types'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import { + asStrictKeyringAccount, + type TronKeyringAccount, +} from '../../entities/keyring-account'; +import { sanitizeSensitiveError } from '../../utils/errors'; +import { getLowestUnusedIndex } from '../../utils/getLowestUnusedIndex'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import { DerivationPathStruct } from '../../validation/structs'; +import type { AssetsService } from '../assets/AssetsService'; +import type { ConfigProvider } from '../config'; +import type { TransactionsService } from '../transactions/TransactionsService'; + +/** + * Elliptic curve for TRON (same as Ethereum) + */ +const CURVE = 'secp256k1' as const; + +export class AccountsService { + readonly #accountsRepository: AccountsRepository; + + readonly #configProvider: ConfigProvider; + + readonly #logger: ILogger; + + readonly #assetsService: AssetsService; + + readonly #transactionsService: TransactionsService; + + readonly #snapClient: SnapClient; + + constructor({ + accountsRepository, + configProvider, + logger, + assetsService, + snapClient, + transactionsService, + }: { + accountsRepository: AccountsRepository; + configProvider: ConfigProvider; + logger: ILogger; + assetsService: AssetsService; + snapClient: SnapClient; + transactionsService: TransactionsService; + }) { + this.#logger = createPrefixedLogger(logger, '[🔑 AccountsService]'); + this.#configProvider = configProvider; + this.#accountsRepository = accountsRepository; + this.#assetsService = assetsService; + this.#transactionsService = transactionsService; + this.#snapClient = snapClient; + } + + /** + * Derives a TRON private and public key from a given derivation path using BIP44. + * The derivation path follows the format: m/44'/195'/account'/change/index + * where 195 is TRON's coin type. + * + * @param params - The parameters for the TRON key derivation. + * @param params.entropySource - The entropy source to use for key derivation. + * @param params.derivationPath - The derivation path to use for key derivation. + * @returns A Promise that resolves to the private key bytes, public key bytes, private key hex WITHOUT the `0x` prefix, and address. + * @throws {Error} If unable to derive private key or if derivation fails. + */ + async deriveTronKeypair({ + entropySource, + derivationPath, + }: { + entropySource?: EntropySourceId | undefined; + derivationPath: string; + }): Promise<{ + privateKeyBytes: Uint8Array; + publicKeyBytes: Uint8Array; + privateKeyHex: string; + address: string; + }> { + try { + this.#logger.log({ derivationPath }, 'Generating TRON wallet'); + + assert(derivationPath, DerivationPathStruct); + + const path = derivationPath.split('/'); + + const node = await this.#snapClient.getBip32Entropy({ + entropySource, + path, + curve: CURVE, + }); + + if (!node.privateKey || !node.publicKey) { + throw new Error('Unable to derive private key'); + } + + const privateKeyBytes = hexToBytes(node.privateKey); + const publicKeyBytes = hexToBytes(node.publicKey); + const privateKeyHex = node.privateKey.slice(2); + + // Derive address from public key (cheaper than from private key) + const hexAddress = computeAddress(node.publicKey); + const address = TronWeb.address.fromHex(hexAddress); + + if (!address) { + throw new Error('Unable to derive address'); + } + + return { + privateKeyBytes, + publicKeyBytes, + privateKeyHex, + address, + }; + } catch (error) { + // Sanitize errors to prevent leaking sensitive cryptographic information + throw sanitizeSensitiveError(error); + } + } + + async deriveAccount({ + entropySource, + index, + }: { + entropySource: EntropySourceId; + index: number; + }): Promise { + const derivationPath = AccountsService.getDefaultDerivationPath(index); + const { address } = await this.deriveTronKeypair({ + entropySource, + derivationPath, + }); + + return { + id: globalThis.crypto.randomUUID(), + entropySource, + derivationPath, + index, + type: TrxAccountType.Eoa, + address, + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropy: { + type: 'mnemonic', + id: entropySource, + derivationPath, + groupIndex: index, + }, + exportable: true, + }, + methods: ['signMessage', 'signTransaction'], + }; + } + + async create(options?: CreateAccountOptions): Promise { + const accounts = await this.#accountsRepository.getAll(); + + const entropySource = + options?.entropySource ?? (await this.#getDefaultEntropySource()); + const index = + options?.index ?? + this.#getLowestUnusedKeyringAccountIndex(accounts, entropySource); + + /** + * Now that we have the `entropySource` and `index` ready, + * we need to make sure that they do not correspond to an existing account already. + */ + const sameAccount = accounts.find( + (account) => + account.index === index && account.entropySource === entropySource, + ); + + if (sameAccount) { + this.#logger.warn( + '[🔑 Keyring] An account already exists with the same derivation path and entropy source. Skipping account creation.', + ); + return asStrictKeyringAccount(sameAccount); + } + + const derivedAccount = await this.deriveAccount({ + entropySource, + index, + }); + + const { metamask: metamaskOptions, ...remainingOptions } = options ?? {}; + + const tronKeyringAccount: TronKeyringAccount = { + ...derivedAccount, + options: { + ...derivedAccount.options, + ...(Object.fromEntries( + Object.entries(remainingOptions).filter( + ([, value]) => value !== undefined, + ), + ) as Record), + groupIndex: index, + }, + }; + + await this.#accountsRepository.create(tronKeyringAccount); + + try { + const keyringAccount = asStrictKeyringAccount(tronKeyringAccount); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { + /** + * We can't pass the `keyringAccount` object because it contains the index + * and the snaps sdk does not allow extra properties. + */ + account: keyringAccount, + /** + * Skip account creation confirmation dialogs to make it look like a native + * account creation flow. + */ + displayConfirmation: false, + /** + * Internal options to MetaMask that includes a correlation ID. We need + * to also emit this ID to the Snap keyring. + */ + ...(metamaskOptions + ? { + metamask: metamaskOptions, + } + : {}), + }); + + return keyringAccount; + } catch (error) { + // Rollback: if the event emission fails after the account was persisted, + // remove it from state so we don't end up with an orphaned record. + try { + await this.#accountsRepository.delete(tronKeyringAccount.id); + } catch (deleteError) { + this.#logger.error( + { deleteError, accountId: tronKeyringAccount.id }, + 'Failed to rollback account creation', + ); + } + throw error; + } + } + + async getAll(): Promise { + return this.#accountsRepository.getAll(); + } + + async getAllSelected(): Promise { + const [allAccounts, selectedAccountIds] = await Promise.all([ + this.#accountsRepository.getAll(), + getSelectedAccounts(snap), + ]); + + return allAccounts.filter((account) => + selectedAccountIds.includes(account.id), + ); + } + + async findById(id: string): Promise { + return this.#accountsRepository.findById(id); + } + + /** + * Retrieves an account by ID and throws an error if not found. + * This is a convenience method that combines findById with validation. + * + * @param id - The account ID to retrieve. + * @returns The account if found. + * @throws {Error} If the account is not found. + */ + async findByIdOrThrow(id: string): Promise { + const account = await this.#accountsRepository.findById(id); + + if (!account) { + throw new Error(`Account with ID ${id} not found`); + } + + return account; + } + + async findByIds(ids: string[]): Promise { + const accounts = await this.#accountsRepository.findByIds(ids); + + if (ids.length !== accounts.length) { + this.#logger.error('[findByIds] Some accounts not found'); + } + + return accounts; + } + + async findByAddress(address: string): Promise { + return this.#accountsRepository.findByAddress(address); + } + + async delete(id: string): Promise { + return this.#accountsRepository.delete(id); + } + + /** + * Synchronizes only assets for the given accounts. + * This method can be called independently to sync assets without syncing transactions. + * + * @param accounts - The accounts to synchronize assets for. + */ + async synchronizeAssets(accounts: TronKeyringAccount[]): Promise { + const scopes = this.#configProvider.get().activeNetworks; + const combinations = accounts.flatMap((account) => + scopes.map((scope) => ({ account, scope })), + ); + + const assetResponses = await Promise.allSettled( + combinations.map(async ({ account, scope }) => { + return this.#assetsService.fetchAssetsAndBalancesForAccount( + scope, + account, + ); + }), + ); + + const assets = assetResponses.flatMap((response) => + response.status === 'fulfilled' ? response.value : [], + ); + + await this.#assetsService.saveMany(assets); + } + + async synchronizeTransactions(accounts: TronKeyringAccount[]): Promise { + const scopes = this.#configProvider.get().activeNetworks; + const combinations = accounts.flatMap((account) => + scopes.map((scope) => ({ account, scope })), + ); + + const transactionResponses = await Promise.allSettled( + combinations.map(async ({ account, scope }) => { + return this.#transactionsService.fetchNewTransactionsForAccount( + scope, + account, + ); + }), + ); + + const transactions = transactionResponses.flatMap((response) => + response.status === 'fulfilled' ? response.value : [], + ); + + await this.#transactionsService.saveMany(transactions); + } + + async synchronize(accounts: TronKeyringAccount[]): Promise { + await Promise.allSettled([ + this.synchronizeAssets(accounts), + this.synchronizeTransactions(accounts), + ]); + } + + #getLowestUnusedKeyringAccountIndex( + accounts: TronKeyringAccount[], + entropySource: EntropySourceId, + ): number { + const accountsFilteredByEntropySourceId = accounts.filter( + (account) => account.entropySource === entropySource, + ); + + return getLowestUnusedIndex(accountsFilteredByEntropySourceId); + } + + static getDefaultDerivationPath(index: number): `m/${string}` { + return `m/44'/195'/0'/0/${index}`; + } + + async #getDefaultEntropySource(): Promise { + const entropySources = await this.#snapClient.listEntropySources(); + const defaultEntropySource = entropySources.find(({ primary }) => primary); + + if (!defaultEntropySource) { + throw new Error( + 'No default entropy source found - this can never happen', + ); + } + + return defaultEntropySource.id; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/accounts/types.ts b/merged-packages/tron-wallet-snap/src/services/accounts/types.ts new file mode 100644 index 00000000..af4ed23d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/accounts/types.ts @@ -0,0 +1,8 @@ +import type { EntropySourceId, MetaMaskOptions } from '@metamask/keyring-api'; +import type { Json } from '@metamask/snaps-sdk'; + +export type CreateAccountOptions = { + entropySource?: EntropySourceId; + index?: number; + [key: string]: Json | undefined; +} & MetaMaskOptions; diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.test.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.test.ts new file mode 100644 index 00000000..1ae41912 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.test.ts @@ -0,0 +1,305 @@ +import { AssetsRepository } from './AssetsRepository'; +import type { NativeCaipAssetType, TokenCaipAssetType } from './types'; +import { KnownCaip19Id, Network } from '../../constants'; +import type { + AssetEntity, + NativeAsset, + TokenAsset, +} from '../../entities/assets'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +describe('AssetsRepository', () => { + const createNativeAsset = ({ + assetType, + keyringAccountId = 'account-1', + network = Network.Mainnet, + decimals = 6, + rawAmount, + uiAmount, + }: { + assetType: NativeCaipAssetType; + keyringAccountId?: string; + network?: Network; + decimals?: number; + rawAmount: string; + uiAmount: string; + }): NativeAsset => ({ + assetType, + keyringAccountId, + network, + symbol: 'TRX', + decimals, + rawAmount, + uiAmount, + iconUrl: '', + }); + + const createTokenAsset = ({ + assetType, + keyringAccountId = 'account-1', + network = Network.Mainnet, + symbol = 'USDT', + decimals = 6, + rawAmount, + uiAmount, + }: { + assetType: TokenCaipAssetType; + keyringAccountId?: string; + network?: Network; + symbol?: string; + decimals?: number; + rawAmount: string; + uiAmount: string; + }): TokenAsset => ({ + assetType, + keyringAccountId, + network, + symbol, + decimals, + rawAmount, + uiAmount, + iconUrl: '', + }); + + const mainnetTrx = (amount: string): AssetEntity => + createNativeAsset({ + assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, + rawAmount: String(Number(amount) * 1_000_000), + uiAmount: amount, + }); + + const nileTrx = (amount: string): AssetEntity => + createNativeAsset({ + assetType: KnownCaip19Id.TrxNile as NativeCaipAssetType, + network: Network.Nile, + rawAmount: String(Number(amount) * 1_000_000), + uiAmount: amount, + }); + + const mainnetUsdt = (amount: string): AssetEntity => + createTokenAsset({ + assetType: + `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType, + rawAmount: String(Number(amount) * 1_000_000), + uiAmount: amount, + }); + + const createRepository = (initialState: UnencryptedStateValue) => { + let stateValue = initialState; + + const mockState: IStateManager = { + get: async () => stateValue, + getKey: async (key: string) => { + if (key === 'assets') { + return stateValue.assets as TResponse; + } + + if (key.startsWith('assets.')) { + const accountId = key.replace('assets.', ''); + return stateValue.assets[accountId] as TResponse; + } + + return undefined; + }, + setKey: async () => undefined, + setKeyWith: async () => undefined, + update: async (updater) => { + stateValue = updater(stateValue); + return stateValue; + }, + deleteKey: async () => undefined, + }; + + return { + repository: new AssetsRepository(mockState), + getState: () => stateValue, + }; + }; + + const createState = ( + assets: UnencryptedStateValue['assets'] = {}, + ): UnencryptedStateValue => ({ + keyringAccounts: {}, + assets, + tokenPrices: {}, + transactions: {}, + mapInterfaceNameToId: {}, + }); + + describe('saveMany', () => { + it('adds a new asset if not already present', async () => { + const trx = mainnetTrx('1'); + const usdt = mainnetUsdt('10'); + const { repository } = createRepository( + createState({ + 'account-1': [trx], + }), + ); + + await repository.saveMany([trx, usdt]); + + expect(await repository.getByAccountId('account-1')).toStrictEqual([ + trx, + usdt, + ]); + }); + + it('creates a new account entry when persisting the first snapshot for that account', async () => { + const trx = createNativeAsset({ + assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, + keyringAccountId: 'new-account', + rawAmount: '1000000', + uiAmount: '1', + }); + const usdt = createTokenAsset({ + assetType: + `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType, + keyringAccountId: 'new-account', + rawAmount: '2500000', + uiAmount: '2.5', + }); + const { repository } = createRepository(createState()); + + await repository.saveMany([trx, usdt]); + + expect(await repository.getByAccountId('new-account')).toStrictEqual([ + trx, + usdt, + ]); + }); + + it('updates an asset balance when it decreases', async () => { + const initialUsdt = mainnetUsdt('10'); + const finalUsdt = mainnetUsdt('4'); + const { repository } = createRepository( + createState({ + 'account-1': [initialUsdt], + }), + ); + + await repository.saveMany([finalUsdt]); + + expect(await repository.getByAccountId('account-1')).toStrictEqual([ + finalUsdt, + ]); + }); + + it('updates an asset balance when it increases', async () => { + const initialUsdt = mainnetUsdt('4'); + const finalUsdt = mainnetUsdt('10'); + const { repository } = createRepository( + createState({ + 'account-1': [initialUsdt], + }), + ); + + await repository.saveMany([finalUsdt]); + + expect(await repository.getByAccountId('account-1')).toStrictEqual([ + finalUsdt, + ]); + }); + + it("updates an asset balance when it's balance goes to 0", async () => { + const trx = mainnetTrx('1'); + const initialUsdt = mainnetUsdt('1'); + const { repository } = createRepository( + createState({ + 'account-1': [trx, initialUsdt], + }), + ); + + const finalUsdt = mainnetUsdt('0'); + await repository.saveMany([trx, finalUsdt]); + + expect(await repository.getByAccountId('account-1')).toStrictEqual([ + trx, + finalUsdt, + ]); + }); + + it('replaces multiple network snapshots for the same account', async () => { + const initialMainnetTrx = mainnetTrx('1'); + const initialMainnetUsdt = mainnetUsdt('10'); + const initialNileTrx = nileTrx('2'); + const initialNileUsdt = createTokenAsset({ + assetType: + `${Network.Nile}/trc20:TNileTokenAddress` as TokenCaipAssetType, + network: Network.Nile, + rawAmount: '7000000', + uiAmount: '7', + }); + const { repository } = createRepository( + createState({ + 'account-1': [ + initialMainnetTrx, + initialMainnetUsdt, + initialNileTrx, + initialNileUsdt, + ], + }), + ); + + const updatedMainnetTrx = mainnetTrx('3'); + const updatedMainnetUsdt = mainnetUsdt('4'); + const updatedNileTrx = nileTrx('5'); + + // Updates only the received assets in the list + await repository.saveMany([ + updatedMainnetTrx, + updatedMainnetUsdt, + updatedNileTrx, + ]); + + expect(await repository.getByAccountId('account-1')).toStrictEqual([ + updatedMainnetTrx, + updatedMainnetUsdt, + updatedNileTrx, + initialNileUsdt, + ]); + }); + + it('keeps unsynchronized network slices untouched when only one network is refreshed', async () => { + const mainnetTrxSnapshot = mainnetTrx('2'); + const nileTrxSnapshot = nileTrx('3'); + const nileUsdtSnapshot = createTokenAsset({ + assetType: + `${Network.Nile}/trc20:TNileTokenAddress` as TokenCaipAssetType, + network: Network.Nile, + rawAmount: '9000000', + uiAmount: '9', + }); + const { repository } = createRepository( + createState({ + 'account-1': [mainnetTrxSnapshot, nileTrxSnapshot, nileUsdtSnapshot], + }), + ); + + const updatedMainnetTrx = mainnetTrx('5'); + await repository.saveMany([updatedMainnetTrx]); + + expect(await repository.getByAccountId('account-1')).toStrictEqual([ + updatedMainnetTrx, + nileTrxSnapshot, + nileUsdtSnapshot, + ]); + }); + + it('does nothing when receives an empty list', async () => { + const trx = mainnetTrx('1'); + const usdt = mainnetUsdt('10'); + const initialState = createState({ + 'account-1': [trx, usdt], + }); + const { repository } = createRepository(initialState); + + await repository.saveMany([]); + + expect(await repository.getByAccountId('account-1')).toStrictEqual([ + trx, + usdt, + ]); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts new file mode 100644 index 00000000..e540b000 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts @@ -0,0 +1,93 @@ +import { cloneDeep } from 'lodash'; + +import type { AssetEntity } from '../../entities/assets'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; + +export class AssetsRepository { + readonly #state: IStateManager; + + constructor(state: IStateManager) { + this.#state = state; + } + + async getByAccountId(keyringAccountId: string): Promise { + const assets = await this.#state.getKey( + `assets.${keyringAccountId}`, + ); + + return assets ?? []; + } + + async getByAccountIdAndAssetType( + keyringAccountId: string, + assetType: string, + ): Promise { + const assets = await this.getByAccountId(keyringAccountId); + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + return assets.find((asset) => asset.assetType === assetType) ?? null; + } + + /** + * Get assets by account ID and asset types. + * + * @param keyringAccountId - The keyring account ID. + * @param assetTypes - The asset types to filter by. + * @returns An array of assets matching the criteria. + */ + async getByAccountIdAndAssetTypes( + keyringAccountId: string, + assetTypes: string[], + ): Promise<(AssetEntity | null)[]> { + const assets = await this.getByAccountId(keyringAccountId); + const result: (AssetEntity | null)[] = []; + + // We iterate through the assetTypes to preserve the order + for (const assetType of assetTypes) { + const asset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (currAsset) => currAsset.assetType === assetType, + ); + result.push(asset ?? null); + } + + return result; + } + + async getAll(): Promise { + const assetsByAccount = + (await this.#state.getKey('assets')) ?? + {}; + + return Object.values(assetsByAccount).flat(); + } + + async saveMany(assets: AssetEntity[]): Promise { + // Update the state atomically + await this.#state.update((stateValue) => { + const newState = cloneDeep(stateValue); + for (const asset of assets) { + const { keyringAccountId } = asset; + const accountAssets = cloneDeep( + newState.assets[keyringAccountId] ?? [], + ); + + // Avoid duplicates. If same asset is already saved, override it. + const existingAssetIndex = accountAssets.findIndex( + (item) => + item.assetType === asset.assetType && + item.keyringAccountId === asset.keyringAccountId, + ); + + if (existingAssetIndex === -1) { + accountAssets.push(asset); + } else { + accountAssets[existingAssetIndex] = asset; + } + + newState.assets[keyringAccountId] = accountAssets; + } + return newState; + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts new file mode 100644 index 00000000..f5d0b9bf --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -0,0 +1,2842 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; + +import type { AssetsRepository } from './AssetsRepository'; +import type { NativeCaipAssetType, TokenCaipAssetType } from './types'; +import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; +import type { SpotPrices } from '../../clients/price-api/types'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; +import type { AccountResources, TronHttpClient } from '../../clients/tron-http'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { Trc20Balance, TronAccount } from '../../clients/trongrid/types'; +import { KnownCaip19Id, Network } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import { mockLogger } from '../../utils/mockLogger'; + +/** + * Subset of State methods. + */ +type MockState = { + getKey: jest.Mock; + setKey: jest.Mock; + setKeyWith: jest.Mock; +}; + +jest.mock('../../context', () => ({ + configProvider: { + get() { + return { + priceApi: { + cacheTtlsMilliseconds: { + fiatExchangeRates: 3600000, + spotPrices: 3600000, + historicalPrices: 3600000, + }, + }, + activeNetworks: [], + }; + }, + }, +})); + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); + +// eslint-disable-next-line no-restricted-globals +(global as any).snap = {}; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals +const { AssetsService } = require('./AssetsService'); + +const mockAccount: KeyringAccount = { + id: 'test-account-id', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], +}; + +const emptyAccountResources: AccountResources = { + freeNetUsed: 0, + freeNetLimit: 0, + NetLimit: 0, + TotalNetLimit: 0, + TotalNetWeight: 0, + tronPowerUsed: 0, + tronPowerLimit: 0, + TotalEnergyLimit: 0, + TotalEnergyWeight: 0, +}; + +/** + * Creates properly typed SpotPrices for tests. + * + * @param entries - Map of asset ID to price info. + * @returns SpotPrices object. + */ +const createSpotPrices = ( + entries: Record, +): SpotPrices => + Object.fromEntries( + Object.entries(entries).map(([key, value]) => [ + key, + { id: value.id, price: value.price }, + ]), + ); + +/** + * Creates a properly typed TronAccount for tests. + * Uses snake_case property names to match Tron API response format. + * + * @param overrides - Partial TronAccount with required address. + * @returns A complete TronAccount. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +const createMockTronAccount = ( + overrides: Partial & { address: string }, +): TronAccount => ({ + owner_permission: { keys: [], threshold: 1, permission_name: 'owner' }, + account_resource: { + energy_window_optimized: false, + energy_window_size: 0, + }, + active_permission: [], + create_time: 0, + latest_opration_time: 0, + frozenV2: [], + unfrozenV2: [], + balance: 0, + trc20: [], + latest_consume_free_time: 0, + votes: [], + latest_withdraw_time: 0, + net_window_size: 0, + net_window_optimized: false, + ...overrides, +}); +/* eslint-enable @typescript-eslint/naming-convention */ + +// Convenience alias used by bandwidth/energy tests +const minimalTronAccount = createMockTronAccount({ + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', +}); + +/** + * Builds a mock AccountResources object matching the shape returned by + * POST https://api.trongrid.io/wallet/getaccountresource. + * + * The Tron full node omits fields with zero values, so all + * account-level fields are optional. Network-level totals use + * sensible mainnet defaults. + * + * @see https://developers.tron.network/reference/getaccountresource + * @param overrides - Account-specific fields to set. + * @returns A mock AccountResources object. + */ +function getMockAccountResources(overrides: Record = {}) { + return { + freeNetLimit: 600, + TotalNetLimit: 0, + TotalNetWeight: 0, + TotalEnergyLimit: 0, + TotalEnergyWeight: 0, + ...overrides, + }; +} + +/** + * Finds an asset by its CAIP-19 asset type. + * + * @param assets - The list of assets to search. + * @param assetType - The CAIP-19 asset type to match. + * @returns The matching asset, or undefined. + */ +function findAsset(assets: AssetEntity[], assetType: KnownCaip19Id) { + return assets.find((a: AssetEntity) => a.assetType === assetType); +} + +type WithAssetsServiceCallback = (payload: { + assetsService: InstanceType; + mockAssetsRepository: jest.Mocked< + Pick< + AssetsRepository, + | 'saveMany' + | 'getByAccountId' + | 'getByAccountIdAndAssetType' + | 'getByAccountIdAndAssetTypes' + > + >; + mockState: MockState; + mockTrongridApiClient: jest.Mocked< + Pick< + TrongridApiClient, + 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' + > + >; + mockTronHttpClient: jest.Mocked< + Pick + >; + mockPriceApiClient: jest.Mocked< + Pick< + PriceApiClient, + 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' + > + >; + mockTokenApiClient: jest.Mocked>; + mockSnapClient: jest.Mocked>; +}) => Promise | ReturnValue; + +/** + * Wraps tests for AssetsService by creating a fresh service with all mock + * dependencies. The callback receives the service and all mocks for + * test configuration. + * + * @param testFunction - The test body receiving the service and mocks. + * @returns The return value of the callback. + */ +async function withAssetsService( + testFunction: WithAssetsServiceCallback, +): Promise { + const mockAssetsRepository: jest.Mocked< + Pick< + AssetsRepository, + | 'getByAccountId' + | 'getByAccountIdAndAssetType' + | 'getByAccountIdAndAssetTypes' + | 'saveMany' + > + > = { + saveMany: jest.fn().mockResolvedValue(undefined), + getByAccountId: jest.fn().mockResolvedValue([]), + getByAccountIdAndAssetType: jest.fn().mockResolvedValue(null), + getByAccountIdAndAssetTypes: jest.fn().mockResolvedValue([]), + }; + + const mockState: MockState = { + getKey: jest.fn().mockResolvedValue({}), + setKey: jest.fn().mockResolvedValue(undefined), + setKeyWith: jest.fn().mockResolvedValue(undefined), + }; + + const mockTrongridApiClient: jest.Mocked< + Pick< + TrongridApiClient, + 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' + > + > = { + getAccountInfoByAddress: jest.fn(), + getTrc20BalancesByAddress: jest.fn(), + }; + + const mockTronHttpClient: jest.Mocked< + Pick + > = { + getAccountResources: jest.fn(), + getReward: jest.fn().mockResolvedValue(0), + }; + + const mockPriceApiClient: jest.Mocked< + Pick< + PriceApiClient, + 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' + > + > = { + getFiatExchangeRates: jest.fn(), + getHistoricalPrices: jest.fn(), + getMultipleSpotPrices: jest.fn().mockResolvedValue({}), + }; + + const mockTokenApiClient: jest.Mocked< + Pick + > = { + getTokensMetadata: jest.fn().mockResolvedValue({}), + }; + + const mockSnapClient: jest.Mocked> = { + trackError: jest.fn().mockResolvedValue(undefined), + }; + + const assetsService = new AssetsService({ + logger: mockLogger, + assetsRepository: mockAssetsRepository, + state: mockState, + trongridApiClient: mockTrongridApiClient, + tronHttpClient: mockTronHttpClient, + priceApiClient: mockPriceApiClient, + tokenApiClient: mockTokenApiClient, + snapClient: mockSnapClient, + }); + + return await testFunction({ + assetsService, + mockAssetsRepository, + mockState, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + mockTokenApiClient, + mockSnapClient, + }); +} + +describe('AssetsService', () => { + describe('fetchAssetsAndBalancesForAccount', () => { + describe('inactive account fallback', () => { + it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + + const trc20Balances = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, + ]; + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + trc20Balances, + ); + + const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, + }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + + const trxAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + + const expectedTrc20AssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const trc20Asset = assets.find( + (asset: AssetEntity) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + asset.assetType === expectedTrc20AssetType, + ); + expect(trc20Asset).toBeDefined(); + expect(trc20Asset?.rawAmount).toBe('24249143'); + }, + ); + }); + + it('returns zero TRX and resources when fallback also returns empty', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + + const trxAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + + const bandwidthAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.BandwidthMainnet, + ); + const energyAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.EnergyMainnet, + ); + expect(bandwidthAsset).toBeDefined(); + expect(energyAsset).toBeDefined(); + }, + ); + }); + + it('gracefully handles fallback endpoint failure', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( + new Error('Network error'), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const trxAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('tracks fallback endpoint errors', async () => { + await withAssetsService( + async ({ + assetsService, + mockSnapClient, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const error = new Error('Network error'); + + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( + error, + ); + + await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); + }, + ); + }); + + it('filters out TRC20 tokens without price data from inactive account', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + + const trc20BalancesWithSpam: Trc20Balance[] = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price + { TSpamToken123456789: '1000000000' }, // Spam token - no price + ]; + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + trc20BalancesWithSpam, + ); + + const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [usdtAssetId]: { id: usdtAssetId, price: 1.0 }, + }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const usdtAssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const usdtAsset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === usdtAssetType, + ); + expect(usdtAsset).toBeDefined(); + + const spamAssetType = `${String(Network.Mainnet)}/trc20:TSpamToken123456789`; + const spamAsset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === spamAssetType, + ); + expect(spamAsset).toBeUndefined(); + }, + ); + }); + }); + + describe('partial failure handling', () => { + it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({ + ...emptyAccountResources, + freeNetLimit: 600, + NetLimit: 0, + EnergyLimit: 0, + }); + + const trc20Balances = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, + ]; + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + trc20Balances, + ); + + const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, + }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + + const trxAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + + const trc20Asset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === trc20AssetId, + ); + expect(trc20Asset).toBeDefined(); + expect(trc20Asset?.rawAmount).toBe('100000'); + }, + ); + }); + + it('continues with zero resources when only resources request fails', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1000000, + trc20: [], + }), + ); + mockTronHttpClient.getAccountResources.mockRejectedValue( + new Error('Resources endpoint unavailable'), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const trxAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('1000000'); + + const bandwidthAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.BandwidthMainnet, + ); + expect(bandwidthAsset).toBeDefined(); + expect(bandwidthAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('tracks spot price errors', async () => { + await withAssetsService( + async ({ + assetsService, + mockSnapClient, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + }) => { + const error = new Error('Spot price endpoint unavailable'); + + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1000000, + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockPriceApiClient.getMultipleSpotPrices.mockRejectedValue(error); + + await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); + }, + ); + }); + }); + + describe('bandwidth', () => { + it('returns 0 when account has no resources', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns remaining free bandwidth when no staking', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ freeNetUsed: 200 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('400'); + }, + ); + }); + + it('returns combined remaining free + staked bandwidth', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('290'); + }, + ); + }); + + it('clamps to 0 when used exceeds maximum', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ + freeNetUsed: 600, + NetUsed: 50, + NetLimit: 16, + }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + }); + + describe('maximum bandwidth', () => { + it('returns 0 when account has no resources', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns only free bandwidth limit when no staking', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({}), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('600'); + }, + ); + }); + + it('returns free + staked bandwidth limit', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ NetLimit: 48 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('648'); + }, + ); + }); + }); + + /* eslint-disable @typescript-eslint/naming-convention */ + describe('TRX ready for withdrawal', () => { + it('returns zero balance when account has no unfrozenV2 data', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('returns ready for withdrawal amount when unfrozenV2 has expired entries', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const pastTime = Date.now() - 1000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); + }, + ); + }); + + it('returns zero balance when unfrozenV2 has not expired', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const futureTime = Date.now() + 1000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { + unfreeze_amount: 1000000, + unfreeze_expire_time: futureTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('sums multiple expired unfrozen entries', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const pastTime1 = Date.now() - 1000; + const pastTime2 = Date.now() - 2000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime1 }, + { unfreeze_amount: 2000000, unfreeze_expire_time: pastTime2 }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('3000000'); + }, + ); + }); + + it('only includes expired entries when mixed with non-expired', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const pastTime = Date.now() - 1000; + const futureTime = Date.now() + 1000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, + { + unfreeze_amount: 5000000, + unfreeze_expire_time: futureTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); + }, + ); + }); + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + /* eslint-disable @typescript-eslint/naming-convention */ + describe('TRX in lock period', () => { + it('returns zero balance when account has no unfrozenV2 data', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('returns in lock period amount when unfrozenV2 has future entries', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const futureTime = Date.now() + 1000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { + unfreeze_amount: 1000000, + unfreeze_expire_time: futureTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('1000000'); + }, + ); + }); + + it('returns zero balance when unfrozenV2 has already expired', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const pastTime = Date.now() - 1000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { + unfreeze_amount: 1000000, + unfreeze_expire_time: pastTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('sums multiple non-expired unfrozen entries', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const futureTime1 = Date.now() + 1000000; + const futureTime2 = Date.now() + 2000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { + unfreeze_amount: 1000000, + unfreeze_expire_time: futureTime1, + }, + { + unfreeze_amount: 2000000, + unfreeze_expire_time: futureTime2, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('3000000'); + }, + ); + }); + + it('only includes non-expired entries when mixed with expired', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const pastTime = Date.now() - 1000; + const futureTime = Date.now() + 1000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, + { + unfreeze_amount: 5000000, + unfreeze_expire_time: futureTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('5000000'); + }, + ); + }); + + it('returns zero balance for inactive accounts', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('account not found'), + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('0'); + }, + ); + }); + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + describe('energy', () => { + it('returns 0 when account has no resources', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns full energy when none consumed', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('329'); + }, + ); + }); + + it('returns remaining energy after partial consumption', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 5000, EnergyUsed: 4383 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('617'); + }, + ); + }); + + it('clamps to 0 when EnergyUsed exceeds EnergyLimit from leasing', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 46, EnergyUsed: 6511 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + }); + + describe('maximum energy', () => { + it('returns 0 when account has no resources', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns EnergyLimit from staking', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, + ).toBe('329'); + }, + ); + }); + }); + + describe('staking rewards', () => { + it('returns 0 when account has no staking rewards', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + mockTronHttpClient.getReward.mockResolvedValue(0); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) + ?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns staking rewards when account has unclaimed rewards', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + mockTronHttpClient.getReward.mockResolvedValue(5000000); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const stakingRewardsAsset = findAsset( + assets, + KnownCaip19Id.TrxStakingRewardsMainnet, + ); + expect(stakingRewardsAsset?.rawAmount).toBe('5000000'); + expect(stakingRewardsAsset?.uiAmount).toBe('5'); + expect(stakingRewardsAsset?.symbol).toBe('trx-staking-rewards'); + }, + ); + }); + + it('gracefully handles staking rewards API failure', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + mockTronHttpClient.getReward.mockRejectedValue( + new Error('API Error'), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) + ?.rawAmount, + ).toBe('0'); + }, + ); + }); + }); + }); + + describe('getHistoricalPrice', () => { + it('tracks historical price errors', async () => { + await withAssetsService( + async ({ assetsService, mockSnapClient, mockPriceApiClient }) => { + const error = new Error('Price error'); + + mockPriceApiClient.getHistoricalPrices.mockRejectedValue(error); + + await assetsService.getHistoricalPrice( + KnownCaip19Id.TrxMainnet, + 'tron:728126428/slip44:usd', + ); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); + }, + ); + }); + }); + + describe('saveMany', () => { + it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const assets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue(assets); + + await assetsService.saveMany(assets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('correctly updates non-essential assets with zero amounts', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const assets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: trc20AssetId, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: assets, + }); + + await assetsService.saveMany(assets); + + expect(await assetsService.getAll()).toStrictEqual(assets); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: [KnownCaip19Id.TrxMainnet], + removed: [trc20AssetId], + }, + }, + }, + ); + }); + }); + + it('updates stale non-essential assets balance to 0 if missed from the latest snapshot', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: trc20AssetId as TokenCaipAssetType, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '1658250000', + uiAmount: '1658.25', + iconUrl: '', + }, + ]; + const finalSavedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: trc20AssetId as TokenCaipAssetType, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [savedAssets[0] as AssetEntity]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + // If an asset is missing from the received list + // - emits the event 'notify:accountAssetListUpdated' with the asset in the 'removed' property + // - emits the event 'notify:accountBalancesUpdated' with the balance for the removed asset sets to 0 + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + finalSavedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: [KnownCaip19Id.TrxMainnet], + removed: [trc20AssetId], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '1', + }, + [trc20AssetId]: { + unit: 'USDT', + amount: '0', + }, + }, + }, + }, + ); + }, + ); + }); + + it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const assets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.MaximumEnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'MAX-ENERGY', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.MaximumBandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'MAX-BANDWIDTH', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue(assets); + + await assetsService.saveMany(assets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.MaximumBandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('keeps staked assets even with zero amounts', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const assets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.TrxStakedForBandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'sTRX-BANDWIDTH', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'sTRX-ENERGY', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue(assets); + + await assetsService.saveMany(assets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('keeps ready for withdrawal assets even with zero amounts', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const assets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.TrxReadyForWithdrawalMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'trx-ready-for-withdrawal', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue(assets); + + await assetsService.saveMany(assets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + describe('updating assets from 0 to >0', () => { + it('adds energy to the asset list when it updates from 0 to >0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '50000', + uiAmount: '50000', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('adds bandwidth to the asset list when it updates from 0 to >0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '1500', + uiAmount: '1500', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('adds TRC20 token to the asset list when it updates from 0 to >0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: trc20AssetId, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: trc20AssetId, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '100000000', + uiAmount: '100', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + trc20AssetId, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('handles multiple assets updating from 0 to >0 simultaneously', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + { + assetType: trc20AssetId, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '50000', + uiAmount: '50000', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '1500', + uiAmount: '1500', + iconUrl: '', + }, + { + assetType: trc20AssetId, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '100000000', + uiAmount: '100', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + trc20AssetId, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('handles staked assets updating from 0 to >0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '5000000', + uiAmount: '5', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'sTRX-ENERGY', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '2000000', + uiAmount: '2', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'sTRX-ENERGY', + decimals: 6, + rawAmount: '3000000', + uiAmount: '3', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + }); + + describe('updating assets going down', () => { + it('updates energy balance when it decreases but remains >0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100000', + uiAmount: '100000', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '35000', + uiAmount: '35000', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + ]), + removed: [], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '1', + }, + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '35000', + }, + }, + }, + }, + ); + }, + ); + }); + + it('updates bandwidth balance when it decreases but remains >0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '5000', + uiAmount: '5000', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '4700', + uiAmount: '4700', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '1', + }, + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '4700', + }, + }, + }, + }, + ); + }, + ); + }); + + it('updates TRC20 token balance when it decreases but remains >0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: trc20AssetId, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '100000000', + uiAmount: '100', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: trc20AssetId, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '50000000', + uiAmount: '50', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + trc20AssetId, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('keeps energy in the list when it drops to 0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '50000', + uiAmount: '50000', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('keeps bandwidth in the list when it drops to 0', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '300', + uiAmount: '300', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('handles both energy and bandwidth fluctuating in a transaction', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '2000000', + uiAmount: '2', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '80000', + uiAmount: '80000', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '1500', + uiAmount: '1500', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '2000000', + uiAmount: '2', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '45000', + uiAmount: '45000', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '1235', + uiAmount: '1235', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '2', + }, + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '45000', + }, + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '1235', + }, + }, + }, + }, + ); + }, + ); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts new file mode 100644 index 00000000..0c44fa95 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -0,0 +1,1888 @@ +import { + KeyringEvent, + type AccountAssetListUpdatedEvent, + type AccountBalancesUpdatedEvent, + type KeyringAccount, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { + AssetConversion, + AssetMetadata, + FungibleAssetMarketData, + FungibleAssetMetadata, + HistoricalPriceIntervals, +} from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import type { CaipAssetType } from '@metamask/utils'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { pick } from 'lodash'; + +import type { AssetsRepository } from './AssetsRepository'; +import type { + InLockPeriodCaipAssetType, + NativeCaipAssetType, + NftCaipAssetType, + ReadyForWithdrawalCaipAssetType, + ResourceCaipAssetType, + StakedCaipAssetType, + StakingRewardsCaipAssetType, + TokenCaipAssetType, +} from './types'; +import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; +import type { + FiatTicker, + SpotPrice, + SpotPrices, +} from '../../clients/price-api/types'; +import { + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + VsCurrencyParamStruct, +} from '../../clients/price-api/types'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; +import type { AccountResources } from '../../clients/tron-http'; +import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { + RawTronUnfrozenV2, + Trc20Balance, + TronAccount, +} from '../../clients/trongrid/types'; +import type { KnownCaip19Id, Network } from '../../constants'; +import { + BANDWIDTH_METADATA, + ENERGY_METADATA, + ESSENTIAL_ASSETS, + MAX_BANDWIDTH_METADATA, + MAX_ENERGY_METADATA, + Networks, + TokenMetadata, + TRX_IN_LOCK_PERIOD_METADATA, + TRX_METADATA, + TRX_READY_FOR_WITHDRAWAL_METADATA, + TRX_STAKED_FOR_BANDWIDTH_METADATA, + TRX_STAKED_FOR_ENERGY_METADATA, + TRX_STAKING_REWARDS_METADATA, +} from '../../constants'; +import { configProvider } from '../../context'; +import type { AssetEntity } from '../../entities/assets'; +import { toUiAmount } from '../../utils/conversion'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import type { State, UnencryptedStateValue } from '../state/State'; + +/** + * Normalized account data structure that provides a consistent shape for both + * active and inactive accounts. This allows extraction functions to work + * without needing to know the account's activation state. + */ +type NormalizedAccountData = { + /** Native TRX balance in sun (0 for inactive accounts). */ + nativeBalance: number; + /** TRC10 token balances as `{ key: tokenId, value: balance }[]` (empty for inactive accounts). */ + trc10Balances: TronAccount['assetV2']; + /** TRC20 token balances from either account info or fallback endpoint. */ + trc20Balances: Trc20Balance[]; + /** Staking data including frozen balances and delegated resources. */ + stakedData: { + frozenV2: TronAccount['frozenV2']; + unfrozenV2: TronAccount['unfrozenV2']; + accountResource: TronAccount['account_resource'] | undefined; + }; + /** Account resources (energy, bandwidth). Empty object for inactive accounts. */ + resources: AccountResources | Record; + /** Unclaimed staking rewards in sun (0 if no rewards). */ + stakingRewards: number; +}; + +export class AssetsService { + readonly #logger: ILogger; + + readonly #assetsRepository: AssetsRepository; + + readonly #state: State; + + readonly #trongridApiClient: TrongridApiClient; + + readonly #tronHttpClient: TronHttpClient; + + readonly #priceApiClient: PriceApiClient; + + readonly #tokenApiClient: TokenApiClient; + + readonly #snapClient: SnapClient; + + readonly cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + + constructor({ + logger, + assetsRepository, + state, + trongridApiClient, + tronHttpClient, + priceApiClient, + tokenApiClient, + snapClient, + }: { + logger: ILogger; + assetsRepository: AssetsRepository; + state: State; + trongridApiClient: TrongridApiClient; + tronHttpClient: TronHttpClient; + priceApiClient: PriceApiClient; + tokenApiClient: TokenApiClient; + snapClient: SnapClient; + }) { + this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); + this.#assetsRepository = assetsRepository; + this.#state = state; + this.#trongridApiClient = trongridApiClient; + this.#tronHttpClient = tronHttpClient; + this.#priceApiClient = priceApiClient; + this.#tokenApiClient = tokenApiClient; + this.#snapClient = snapClient; + + const { cacheTtlsMilliseconds } = configProvider.get().priceApi; + this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; + } + + static isFiat(caipAssetId: CaipAssetType): boolean { + return caipAssetId.includes('swift:0/iso4217:'); + } + + async getAllAssetsByAccountId(accountId: string): Promise { + return this.#assetsRepository.getByAccountId(accountId); + } + + async getAssetsByAccountId( + accountId: string, + assetTypes: string[], + ): Promise<(AssetEntity | null)[]> { + return this.#assetsRepository.getByAccountIdAndAssetTypes( + accountId, + assetTypes, + ); + } + + async getAssetByAccountId( + accountId: string, + assetType: string, + ): Promise { + return this.#assetsRepository.getByAccountIdAndAssetType( + accountId, + assetType, + ); + } + + /** + * Fetches all assets and balances for an account. + * + * Data Sources: + * - `getAccountInfoByAddress`: TRX balance, TRC10 tokens, TRC20 tokens (active accounts only) + * - `getAccountResources`: Energy and Bandwidth (returns {} for inactive accounts) + * - `getTrc20BalancesByAddress`: TRC20 balances fallback (works for inactive accounts) + * + * Logic Flow: + * 1. Fetch account info, resources, and TRC20 fallback (for inactive accounts) + * 2. Normalize data into consistent shape via `#buildAccountData` + * 3. Extract all assets via `#extractAssets` + * 4. Fetch metadata and prices in parallel + * 5. Enrich assets with metadata via `#enrichAssetsWithMetadata` + * 6. Filter spam tokens via `#filterTokensWithoutPriceData` + * + * @param scope - The network to query. + * @param account - The keyring account. + * @returns Promise - Array of assets with balances. + */ + async fetchAssetsAndBalancesForAccount( + scope: Network, + account: KeyringAccount, + ): Promise { + this.#logger.info('Fetching assets and balances by account', { + account, + scope, + }); + + const [ + tronAccountInfoRequest, + tronAccountResourcesRequest, + stakingRewardsRequest, + ] = await Promise.allSettled([ + this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), + this.#tronHttpClient.getAccountResources(scope, account.address), + this.#tronHttpClient.getReward(scope, account.address), + ]); + + const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; + if (isInactiveAccount) { + this.#logger.info( + 'Account info request failed, treating as inactive account', + { account, scope }, + ); + } + + const trc20BalancesFallback = isInactiveAccount + ? await this.#trongridApiClient + .getTrc20BalancesByAddress(scope, account.address) + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + 'Failed to fetch TRC20 balances for inactive account', + { error, account, scope }, + ); + return []; + }) + : []; + + const accountData = this.#buildAccountData({ + tronAccountInfoRequest, + tronAccountResourcesRequest, + trc20BalancesFallback, + stakingRewardsRequest, + }); + + const rawAssets = this.#extractAssets(account, scope, accountData); + + const assetTypes = rawAssets.map((asset) => asset.assetType); + const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); + + const [assetsMetadata, spotPrices] = await Promise.all([ + this.getAssetsMetadata(assetTypes), + this.#priceApiClient + .getMultipleSpotPrices(priceableAssetTypes, 'usd') + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + return {}; + }), + ]); + + const enrichedAssets = this.#enrichAssetsWithMetadata( + rawAssets, + assetsMetadata, + ); + return this.#filterTokensWithoutPriceData(enrichedAssets, spotPrices); + } + + /** + * Filters out spam tokens (those without price data). + * Essential assets are always kept. Tokens need price data to be included. + * + * @param assets - The assets to filter. + * @param spotPrices - Pre-fetched USD prices for assets. + * @returns The filtered assets. + */ + #filterTokensWithoutPriceData( + assets: AssetEntity[], + spotPrices: SpotPrices | Record, + ): AssetEntity[] { + const filtered = assets.filter((asset) => { + // Essential assets (TRX, staked, energy, bandwidth) are always kept + if (ESSENTIAL_ASSETS.includes(asset.assetType)) { + return true; + } + // Tokens: keep only if they have price data + const spotPrice = (spotPrices as SpotPrices)[asset.assetType]; + return typeof spotPrice?.price === 'number'; + }); + + return filtered; + } + + /** + * Normalizes raw API responses into a consistent shape for both active and inactive accounts. + * This allows extraction functions to work without needing to know the account's activation state. + * + * @param params - The raw API responses to normalize. + * @param params.tronAccountInfoRequest - The settled promise result from getAccountInfoByAddress. + * @param params.tronAccountResourcesRequest - The settled promise result from getAccountResources. + * @param params.trc20BalancesFallback - TRC20 balances from fallback endpoint (empty for active accounts). + * @param params.stakingRewardsRequest - The settled promise result from getReward. + * @returns NormalizedAccountData - Consistent data shape for extraction. + */ + #buildAccountData({ + tronAccountInfoRequest, + tronAccountResourcesRequest, + trc20BalancesFallback, + stakingRewardsRequest, + }: { + tronAccountInfoRequest: PromiseSettledResult; + tronAccountResourcesRequest: PromiseSettledResult; + trc20BalancesFallback: Trc20Balance[]; + stakingRewardsRequest: PromiseSettledResult; + }): NormalizedAccountData { + const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; + const resources = + tronAccountResourcesRequest.status === 'fulfilled' + ? tronAccountResourcesRequest.value + : {}; + const stakingRewards = + stakingRewardsRequest.status === 'fulfilled' + ? Math.max(0, stakingRewardsRequest.value) + : 0; + + if (isInactiveAccount) { + return { + nativeBalance: 0, + trc10Balances: [], + trc20Balances: trc20BalancesFallback, + stakedData: { + frozenV2: [], + unfrozenV2: [], + accountResource: undefined, + }, + resources, + stakingRewards, + }; + } + + const tronAccountInfo = tronAccountInfoRequest.value; + return { + nativeBalance: tronAccountInfo.balance ?? 0, + trc10Balances: tronAccountInfo.assetV2 ?? [], + trc20Balances: tronAccountInfo.trc20 ?? [], + stakedData: { + frozenV2: tronAccountInfo.frozenV2 ?? [], + unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], + accountResource: tronAccountInfo.account_resource, + }, + resources, + stakingRewards, + }; + } + + /** + * Extracts all assets from normalized account data. + * Coordinates calls to individual extraction functions. + * + * @param account - The keyring account. + * @param scope - The network. + * @param data - Normalized account data. + * @returns AssetEntity[] - Array of all extracted assets. + */ + #extractAssets( + account: KeyringAccount, + scope: Network, + data: NormalizedAccountData, + ): AssetEntity[] { + return [ + this.#extractNativeAsset(account, scope, data.nativeBalance), + ...this.#extractStakedNativeAssets(account, scope, data.stakedData), + this.#extractReadyForWithdrawalAsset(account, scope, data.stakedData), + this.#extractInLockPeriodAsset(account, scope, data.stakedData), + this.#extractStakingRewardsAsset(account, scope, data.stakingRewards), + ...this.#extractTrc10Assets(account, scope, data.trc10Balances), + ...this.#extractTrc20Assets(account, scope, data.trc20Balances), + ...this.#extractBandwidth({ + account, + scope, + tronAccountResources: data.resources, + }), + ...this.#extractEnergy({ + account, + scope, + tronAccountResources: data.resources, + }), + ]; + } + + /** + * Returns the asset types that can be priced (native, TRC10, TRC20). + * Staked, energy, and bandwidth assets have non-compliant CAIP IDs that would fail the Price API. + * + * @param assets - Array of assets to filter. + * @returns CaipAssetType[] - Array of priceable asset types. + */ + #getPriceableAssetTypes(assets: AssetEntity[]): CaipAssetType[] { + return assets + .filter( + (asset) => + asset.assetType.includes('/slip44:') || + asset.assetType.includes('/trc10:') || + asset.assetType.includes('/trc20:'), + ) + .map((asset) => asset.assetType); + } + + /** + * Enriches assets with metadata (symbol, decimals, iconUrl) and calculates uiAmount. + * + * @param assets - Raw assets to enrich. + * @param assetsMetadata - Metadata lookup by asset type. + * @returns AssetEntity[] - Enriched assets. + */ + #enrichAssetsWithMetadata( + assets: AssetEntity[], + assetsMetadata: Record, + ): AssetEntity[] { + return assets.map((asset) => { + const metadata = assetsMetadata[ + asset.assetType + ] as FungibleAssetMetadata | null; + + const { + symbol: initialSymbol, + decimals: initialDecimals = 0, + iconUrl: initialIconUrl, + } = asset; + let symbol = initialSymbol; + let decimals = initialDecimals; + let iconUrl = initialIconUrl; + + if (metadata?.fungible) { + const unit = metadata.units?.[0]; + if (unit) { + symbol = unit.symbol ?? metadata.symbol ?? symbol; + decimals = unit.decimals ?? decimals; + } else { + symbol = metadata?.symbol ?? symbol; + } + iconUrl = metadata.iconUrl ?? iconUrl; + } + + const uiAmount = toUiAmount(asset.rawAmount, decimals).toString(); + + return { + ...asset, + symbol, + decimals, + uiAmount, + iconUrl, + }; + }); + } + + /** + * Extracts the native TRX asset from the balance. + * + * @param account - The keyring account. + * @param scope - The network. + * @param balance - The native balance in sun. + * @returns AssetEntity - The native TRX asset. + */ + #extractNativeAsset( + account: KeyringAccount, + scope: Network, + balance: number, + ): AssetEntity { + return { + assetType: Networks[scope].nativeToken.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].nativeToken.symbol, + decimals: Networks[scope].nativeToken.decimals, + rawAmount: balance.toString(), + uiAmount: toUiAmount( + balance, + Networks[scope].nativeToken.decimals, + ).toString(), + iconUrl: Networks[scope].nativeToken.iconUrl, + }; + } + + /** + * Extracts staked TRX assets (for bandwidth and energy). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including frozen balances and delegated resources. + * @returns AssetEntity[] - Array of staked assets (always 2: bandwidth and energy, amounts may be 0). + */ + #extractStakedNativeAssets( + account: KeyringAccount, + scope: Network, + stakedData: NormalizedAccountData['stakedData'], + ): AssetEntity[] { + const assets: AssetEntity[] = []; + + let stakedBandwidthAmount = 0; + let stakedEnergyAmount = 0; + + stakedData.frozenV2?.forEach((frozen) => { + const amount = frozen.amount ?? 0; + + if (frozen.type === 'ENERGY') { + stakedEnergyAmount += amount; + } else if (!frozen.type) { + // Item without type is for bandwidth + stakedBandwidthAmount += amount; + } + }); + + const delegatedBandwidth = + stakedData.accountResource?.delegated_frozenV2_balance_for_bandwidth ?? 0; + const delegatedEnergy = + stakedData.accountResource?.delegated_frozenV2_balance_for_energy ?? 0; + + stakedBandwidthAmount += delegatedBandwidth; + stakedEnergyAmount += delegatedEnergy; + + assets.push({ + assetType: Networks[scope].stakedForBandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakedForBandwidth.symbol, + decimals: Networks[scope].stakedForBandwidth.decimals, + rawAmount: stakedBandwidthAmount.toString(), + uiAmount: toUiAmount( + stakedBandwidthAmount, + Networks[scope].stakedForBandwidth.decimals, + ).toString(), + iconUrl: Networks[scope].stakedForBandwidth.iconUrl, + }); + + assets.push({ + assetType: Networks[scope].stakedForEnergy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakedForEnergy.symbol, + decimals: Networks[scope].stakedForEnergy.decimals, + rawAmount: stakedEnergyAmount.toString(), + uiAmount: toUiAmount( + stakedEnergyAmount, + Networks[scope].stakedForEnergy.decimals, + ).toString(), + iconUrl: Networks[scope].stakedForEnergy.iconUrl, + }); + + return assets; + } + + /** + * Extracts TRX ready for withdrawal (unstaked TRX that has completed the withdrawal period). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including unfrozen balances. + * @returns AssetEntity - The ready-for-withdrawal asset (amount may be 0). + */ + #extractReadyForWithdrawalAsset( + account: KeyringAccount, + scope: Network, + stakedData: NormalizedAccountData['stakedData'], + ): AssetEntity { + const currentTimestamp = Date.now(); + let readyForWithdrawalAmount = 0; + + stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { + const expireTime = unfrozen.unfreeze_expire_time ?? 0; + const amount = unfrozen.unfreeze_amount ?? 0; + + if (expireTime <= currentTimestamp && amount > 0) { + readyForWithdrawalAmount += amount; + } + }); + + const { id, symbol, decimals, iconUrl } = + Networks[scope].readyForWithdrawal; + + return { + assetType: id, + keyringAccountId: account.id, + network: scope, + symbol, + decimals, + rawAmount: readyForWithdrawalAmount.toString(), + uiAmount: toUiAmount(readyForWithdrawalAmount, decimals).toString(), + iconUrl, + }; + } + + /** + * Extracts staking rewards asset (unclaimed voting rewards). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakingRewards - Unclaimed staking rewards in sun. + * @returns AssetEntity - The staking rewards asset. + */ + #extractStakingRewardsAsset( + account: KeyringAccount, + scope: Network, + stakingRewards: number, + ): AssetEntity { + return { + assetType: Networks[scope].stakingRewards.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakingRewards.symbol, + decimals: Networks[scope].stakingRewards.decimals, + rawAmount: stakingRewards.toString(), + uiAmount: toUiAmount( + stakingRewards, + Networks[scope].stakingRewards.decimals, + ).toString(), + iconUrl: Networks[scope].stakingRewards.iconUrl, + }; + } + + /** + * Extracts TRX that is in the lock period (unstaked but lock period not yet ended). + * This represents TRX that the user has initiated unstaking for but must wait + * the 14-day lock period before they can withdraw. + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including unfrozen balances. + * @returns AssetEntity - The in-lock-period asset (amount may be 0). + */ + #extractInLockPeriodAsset( + account: KeyringAccount, + scope: Network, + stakedData: NormalizedAccountData['stakedData'], + ): AssetEntity { + const currentTimestamp = Date.now(); + let inLockPeriodAmount = 0; + + stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { + const expireTime = unfrozen.unfreeze_expire_time ?? 0; + const amount = unfrozen.unfreeze_amount ?? 0; + + if (expireTime > currentTimestamp && amount > 0) { + inLockPeriodAmount += amount; + } + }); + + const { id, symbol, decimals, iconUrl } = Networks[scope].inLockPeriod; + + return { + assetType: id, + keyringAccountId: account.id, + network: scope, + symbol, + decimals, + rawAmount: inLockPeriodAmount.toString(), + uiAmount: toUiAmount(inLockPeriodAmount, decimals).toString(), + iconUrl, + }; + } + + /** + * Extracts current and maximum bandwidth from the account resources. + * + * @param options - Options object. + * @param options.account - The account to extract bandwidth for. + * @param options.scope - The network to extract bandwidth for. + * @param options.tronAccountResources - The account resources to extract bandwidth for. + * @returns The bandwidth assets. + */ + #extractBandwidth({ + account, + scope, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources | Record; + }): AssetEntity[] { + const freeBandwidth = tronAccountResources?.freeNetLimit ?? 0; + const stakingBandwidth = tronAccountResources?.NetLimit ?? 0; + const maximumBandwidth = freeBandwidth + stakingBandwidth; + + const usedFreeBandwidth = tronAccountResources?.freeNetUsed ?? 0; + const usedStakingBandwidth = tronAccountResources?.NetUsed ?? 0; + const usedBandwidth = usedFreeBandwidth + usedStakingBandwidth; + + const availableBandwidth = Math.max(0, maximumBandwidth - usedBandwidth); + + return [ + { + assetType: Networks[scope].bandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].bandwidth.symbol, + decimals: Networks[scope].bandwidth.decimals, + rawAmount: availableBandwidth.toString(), + uiAmount: availableBandwidth.toString(), + iconUrl: Networks[scope].bandwidth.iconUrl, + }, + { + assetType: Networks[scope].maximumBandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].maximumBandwidth.symbol, + decimals: Networks[scope].maximumBandwidth.decimals, + rawAmount: maximumBandwidth.toString(), + uiAmount: maximumBandwidth.toString(), + iconUrl: Networks[scope].maximumBandwidth.iconUrl, + }, + ]; + } + + /** + * Extracts current and maximum energy from the account resources. + * + * @param options - Options object. + * @param options.account - The keyring account. + * @param options.scope - The network. + * @param options.tronAccountResources - Account resources (energy, bandwidth). + * @returns AssetEntity[] - Array containing energy and maximum energy assets. + */ + #extractEnergy({ + account, + scope, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources | Record; + }): AssetEntity[] { + const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; + const usedEnergy = tronAccountResources?.EnergyUsed ?? 0; + + /** + * We might have used more Energy than the maximum allocated + */ + const availableEnergy = Math.max(0, maximumEnergy - usedEnergy); + + return [ + { + assetType: Networks[scope].energy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].energy.symbol, + decimals: Networks[scope].energy.decimals, + rawAmount: availableEnergy.toString(), + uiAmount: availableEnergy.toString(), + iconUrl: Networks[scope].energy.iconUrl, + }, + { + assetType: Networks[scope].maximumEnergy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].maximumEnergy.symbol, + decimals: Networks[scope].maximumEnergy.decimals, + rawAmount: maximumEnergy.toString(), + uiAmount: maximumEnergy.toString(), + iconUrl: Networks[scope].maximumEnergy.iconUrl, + }, + ]; + } + + /** + * Extracts TRC10 assets from the balances array. + * + * @param account - The keyring account. + * @param scope - The network. + * @param trc10Balances - TRC10 token balances as `{ key: tokenId, value: balance }[]`. + * @returns AssetEntity[] - Array of TRC10 asset entities. + */ + #extractTrc10Assets( + account: KeyringAccount, + scope: Network, + trc10Balances: TronAccount['assetV2'], + ): AssetEntity[] { + return ( + trc10Balances?.flatMap((tokenObject) => { + // assetV2 has structure: { "key": "token_id", "value": "balance" } + return { + assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + symbol: '', + decimals: 0, + rawAmount: tokenObject.value?.toString() ?? '0', + uiAmount: '0', + iconUrl: '', // Will be enriched with metadata later + }; + }) ?? [] + ); + } + + /** + * Extracts TRC20 assets from a balances array. + * Works with both active accounts (tronAccountInfo.trc20) and inactive accounts (getTrc20BalancesByAddress). + * + * @param account - The keyring account. + * @param scope - The network. + * @param trc20Balances - Array of `Record` objects (e.g., `[{ "TContractAddr": "1000" }]`). + * @returns AssetEntity[] - Array of TRC20 asset entities. + */ + #extractTrc20Assets( + account: KeyringAccount, + scope: Network, + trc20Balances: Trc20Balance[], + ): AssetEntity[] { + return trc20Balances.flatMap((tokenObject) => { + return Object.entries(tokenObject).map(([address, balance]) => { + return { + assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + symbol: '', + decimals: 0, + rawAmount: balance, + uiAmount: '0', + iconUrl: '', // Will be enriched with metadata later + }; + }); + }); + } + + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + this.#logger.info('Fetching metadata for assets', assetTypes); + + const { + nativeAssetTypes, + stakedNativeAssetTypes, + readyForWithdrawalAssetTypes, + inLockPeriodAssetTypes, + stakingRewardsAssetTypes, + energyAssetTypes, + maximunEnergyAssetTypes, + bandwidthAssetTypes, + maximunBandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + } = this.#splitAssetsByType(assetTypes); + + const nativeTokensMetadata = + this.#getNativeTokensMetadata(nativeAssetTypes); + const stakedTokensMetadata = this.#getStakedTokensMetadata( + stakedNativeAssetTypes, + ); + const readyForWithdrawalTokensMetadata = + this.#getReadyForWithdrawalTokensMetadata(readyForWithdrawalAssetTypes); + const inLockPeriodTokensMetadata = this.#getInLockPeriodMetadata( + inLockPeriodAssetTypes, + ); + const stakingRewardsMetadata = this.#getStakingRewardsMetadata( + stakingRewardsAssetTypes, + ); + const energyTokensMetadata = this.#getEnergyMetadata(energyAssetTypes); + const maximunEnergyTokensMetadata = this.#getMaximunEnergyMetadata( + maximunEnergyAssetTypes, + ); + const bandwidthTokensMetadata = + this.#getBandwidthMetadata(bandwidthAssetTypes); + const maximunBandwidthTokensMetadata = this.#getMaximunBandwidthMetadata( + maximunBandwidthAssetTypes, + ); + const tokensMetadata = await this.#getTokensMetadata([ + ...tokenTrc10AssetTypes, + ...tokenTrc20AssetTypes, + ]); + + const result = { + ...nativeTokensMetadata, + ...stakedTokensMetadata, + ...readyForWithdrawalTokensMetadata, + ...inLockPeriodTokensMetadata, + ...stakingRewardsMetadata, + ...energyTokensMetadata, + ...maximunEnergyTokensMetadata, + ...bandwidthTokensMetadata, + ...maximunBandwidthTokensMetadata, + ...tokensMetadata, + }; + + this.#logger.info('Resolved assets metadata', { assetTypes, result }); + + return result; + } + + #splitAssetsByType(assetTypes: CaipAssetType[]): { + nativeAssetTypes: NativeCaipAssetType[]; + stakedNativeAssetTypes: StakedCaipAssetType[]; + readyForWithdrawalAssetTypes: ReadyForWithdrawalCaipAssetType[]; + inLockPeriodAssetTypes: InLockPeriodCaipAssetType[]; + stakingRewardsAssetTypes: StakingRewardsCaipAssetType[]; + energyAssetTypes: ResourceCaipAssetType[]; + maximunEnergyAssetTypes: ResourceCaipAssetType[]; + bandwidthAssetTypes: ResourceCaipAssetType[]; + maximunBandwidthAssetTypes: ResourceCaipAssetType[]; + tokenTrc10AssetTypes: TokenCaipAssetType[]; + tokenTrc20AssetTypes: TokenCaipAssetType[]; + nftAssetTypes: NftCaipAssetType[]; + } { + const nativeAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195'), + ) as NativeCaipAssetType[]; + const stakedNativeAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/slip44:195-staked-for-'), + ) as StakedCaipAssetType[]; + const readyForWithdrawalAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-ready-for-withdrawal'), + ) as ReadyForWithdrawalCaipAssetType[]; + const inLockPeriodAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-in-lock-period'), + ) as InLockPeriodCaipAssetType[]; + const stakingRewardsAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-staking-rewards'), + ) as StakingRewardsCaipAssetType[]; + const energyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:energy'), + ) as ResourceCaipAssetType[]; + const maximunEnergyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-energy'), + ) as ResourceCaipAssetType[]; + const bandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:bandwidth'), + ) as ResourceCaipAssetType[]; + const maximunBandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-bandwidth'), + ) as ResourceCaipAssetType[]; + const tokenTrc10AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc10:'), + ) as TokenCaipAssetType[]; + const tokenTrc20AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc20:'), + ) as TokenCaipAssetType[]; + const nftAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc721:'), + ) as NftCaipAssetType[]; + + return { + nativeAssetTypes, + stakedNativeAssetTypes, + readyForWithdrawalAssetTypes, + inLockPeriodAssetTypes, + stakingRewardsAssetTypes, + energyAssetTypes, + maximunEnergyAssetTypes, + bandwidthAssetTypes, + maximunBandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + nftAssetTypes, + }; + } + + #getNativeTokensMetadata( + assetTypes: NativeCaipAssetType[], + ): Record { + const nativeTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + nativeTokensMetadata[assetType] = { + fungible: TRX_METADATA.fungible, + name: TRX_METADATA.name, + symbol: TRX_METADATA.symbol, + iconUrl: TRX_METADATA.iconUrl, + units: [ + { + decimals: TRX_METADATA.decimals, + symbol: TRX_METADATA.symbol, + name: TRX_METADATA.name, + }, + ], + }; + } + + return nativeTokensMetadata; + } + + #getStakedTokensMetadata( + assetTypes: StakedCaipAssetType[], + ): Record { + // Can either be Staked for Bandwidth or Staked for Energy + const stakedTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); + + if (isForBandwdidth) { + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_BANDWIDTH_METADATA.fungible, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_BANDWIDTH_METADATA.decimals, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + const isForEnergy = assetType.endsWith('staked-for-energy'); + + if (isForEnergy) { + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_ENERGY_METADATA.fungible, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_ENERGY_METADATA.decimals, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + }, + ], + }; + } + } + + return stakedTokensMetadata; + } + + #getReadyForWithdrawalTokensMetadata( + assetTypes: ReadyForWithdrawalCaipAssetType[], + ): Record { + const readyForWithdrawalTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + readyForWithdrawalTokensMetadata[assetType] = { + fungible: TRX_READY_FOR_WITHDRAWAL_METADATA.fungible, + name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, + symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, + iconUrl: TRX_READY_FOR_WITHDRAWAL_METADATA.iconUrl, + units: [ + { + decimals: TRX_READY_FOR_WITHDRAWAL_METADATA.decimals, + symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, + name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, + }, + ], + }; + } + + return readyForWithdrawalTokensMetadata; + } + + #getStakingRewardsMetadata( + assetTypes: StakingRewardsCaipAssetType[], + ): Record { + const stakingRewardsMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + stakingRewardsMetadata[assetType] = { + fungible: TRX_STAKING_REWARDS_METADATA.fungible, + name: TRX_STAKING_REWARDS_METADATA.name, + symbol: TRX_STAKING_REWARDS_METADATA.symbol, + iconUrl: TRX_STAKING_REWARDS_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKING_REWARDS_METADATA.decimals, + symbol: TRX_STAKING_REWARDS_METADATA.symbol, + name: TRX_STAKING_REWARDS_METADATA.name, + }, + ], + }; + } + + return stakingRewardsMetadata; + } + + #getInLockPeriodMetadata( + assetTypes: InLockPeriodCaipAssetType[], + ): Record { + const inLockPeriodTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + inLockPeriodTokensMetadata[assetType] = { + fungible: TRX_IN_LOCK_PERIOD_METADATA.fungible, + name: TRX_IN_LOCK_PERIOD_METADATA.name, + symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, + iconUrl: TRX_IN_LOCK_PERIOD_METADATA.iconUrl, + units: [ + { + decimals: TRX_IN_LOCK_PERIOD_METADATA.decimals, + symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, + name: TRX_IN_LOCK_PERIOD_METADATA.name, + }, + ], + }; + } + + return inLockPeriodTokensMetadata; + } + + #getBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const bandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + bandwidthTokensMetadata[assetType] = { + fungible: BANDWIDTH_METADATA.fungible, + name: BANDWIDTH_METADATA.name, + symbol: BANDWIDTH_METADATA.symbol, + iconUrl: BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: BANDWIDTH_METADATA.decimals, + symbol: BANDWIDTH_METADATA.symbol, + name: BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return bandwidthTokensMetadata; + } + + #getMaximunBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunBandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunBandwidthTokensMetadata[assetType] = { + fungible: MAX_BANDWIDTH_METADATA.fungible, + name: MAX_BANDWIDTH_METADATA.name, + symbol: MAX_BANDWIDTH_METADATA.symbol, + iconUrl: MAX_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: MAX_BANDWIDTH_METADATA.decimals, + symbol: MAX_BANDWIDTH_METADATA.symbol, + name: MAX_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return maximunBandwidthTokensMetadata; + } + + #getEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const energyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + energyTokensMetadata[assetType] = { + fungible: ENERGY_METADATA.fungible, + name: ENERGY_METADATA.name, + symbol: ENERGY_METADATA.symbol, + iconUrl: ENERGY_METADATA.iconUrl, + units: [ + { + decimals: ENERGY_METADATA.decimals, + symbol: ENERGY_METADATA.symbol, + name: ENERGY_METADATA.name, + }, + ], + }; + } + + return energyTokensMetadata; + } + + #getMaximunEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunEnergyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunEnergyTokensMetadata[assetType] = { + fungible: MAX_ENERGY_METADATA.fungible, + name: MAX_ENERGY_METADATA.name, + symbol: MAX_ENERGY_METADATA.symbol, + iconUrl: MAX_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: MAX_ENERGY_METADATA.decimals, + symbol: MAX_ENERGY_METADATA.symbol, + name: MAX_ENERGY_METADATA.name, + }, + ], + }; + } + + return maximunEnergyTokensMetadata; + } + + async #getTokensMetadata( + assetTypes: TokenCaipAssetType[], + ): Promise> { + return this.#tokenApiClient.getTokensMetadata(assetTypes); + } + + /** + * Checks if the asset has changed compared to passed assets lookup. + * + * @param asset - The asset to check. + * @param assetsLookup - The lookup table to check against. + * @returns True if the asset has changed, false otherwise. + */ + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { + const savedAsset = assetsLookup.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + if (!savedAsset) { + return true; + } + + return savedAsset.rawAmount !== asset.rawAmount; + } + + /** + * Persist the latest fetched assets and emit the corresponding keyring events. + * + * The input is treated as the latest snapshot for the account/network pairs + * included in this sync. The method compares that snapshot with the + * previously saved state to detect disappeared assets, emits asset-list + * updates, and emits balance updates including synthetic zero balances for + * assets that vanished from the latest response. + * + * @param assets - The latest asset snapshot returned by the refresh flow. + */ + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Saving assets', assets); + + /** + * Should we save the assets incrementally? + * - If true, only saves and emits events for the assets that have changed (new or balance changed). Better performance because it only informs the client of what has changed. + * - If false, saves all assets. More reliable because it enforces that the client has the same state of assets as the snap. + */ + const isIncremental = false; + + const hasZeroAmount = (asset: AssetEntity): boolean => + asset.rawAmount === '0' || asset.uiAmount === '0'; + + const hasNonZeroAmount = (asset: AssetEntity): boolean => + !hasZeroAmount(asset); + + const savedAssets = await this.getAll(); + const isEssentialAsset = (asset: AssetEntity): boolean => + ESSENTIAL_ASSETS.includes(asset.assetType); + + // Track only the account/network pairs refreshed in this run. + // That prevents us from treating assets from untouched networks as disappeared. + const syncedNetworksByAccount = assets.reduce>>( + (acc, asset) => { + acc[asset.keyringAccountId] ??= new Set(); + acc[asset.keyringAccountId]?.add(asset.network); + return acc; + }, + {}, + ); + + const incomingAssetKeys = new Set( + assets.map((asset) => `${asset.keyringAccountId}:${asset.assetType}`), + ); + + // A saved asset is considered disappeared only if its network was part of + // this sync, it is not essential, and it is missing from the latest + // snapshot for that account. + const disappearedAssets = savedAssets.filter((savedAsset) => { + const syncedNetworks = + syncedNetworksByAccount[savedAsset.keyringAccountId]; + + if ( + !syncedNetworks?.has(savedAsset.network) || + isEssentialAsset(savedAsset) + ) { + return false; + } + + return !incomingAssetKeys.has( + `${savedAsset.keyringAccountId}:${savedAsset.assetType}`, + ); + }); + + // Notify the extension about the new assets in a single event + const isNew = (asset: AssetEntity): boolean => + !savedAssets.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + const wasSavedWithZeroAmount = (asset: AssetEntity): boolean => { + const savedAsset = savedAssets.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + return Boolean(savedAsset && hasZeroAmount(savedAsset)); + }; + + // A token should be removed from the visible asset list only when the latest + // snapshot says its balance is zero. Essential assets stay visible even at + // zero because they are part of the permanent Tron account model. + const shouldBeInRemovedList = (asset: AssetEntity): boolean => + hasZeroAmount(asset) && !isEssentialAsset(asset); // Never remove essential assets (including energy & bandwidth) from the account asset list + + // Assets are added to the visible list when they are non-zero and either: + // - we are doing a full non-incremental broadcast, or + // - they are brand new, or + // - they existed before with zero balance and now became non-zero. + const shouldBeInAddedList = (asset: AssetEntity): boolean => + !shouldBeInRemovedList(asset) && + (!isIncremental || + ((isNew(asset) || wasSavedWithZeroAmount(asset)) && + hasNonZeroAmount(asset))); + + // Build the asset-list payload in two stages: + // 1. seed the removed list with assets that vanished from the latest + // snapshot entirely + // 2. fold in the current assets to report additions and explicit zero-balance + // removals in the same event + const assetListUpdatedPayload = disappearedAssets.reduce< + AccountAssetListUpdatedEvent['params']['assets'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [...(acc[asset.keyringAccountId]?.added ?? [])], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + asset.assetType, + ], + }, + }), + {}, + ); + + for (const asset of assets) { + // Merge the current snapshot into the pre-seeded payload so each account + // ends up with one consolidated added/removed diff. + assetListUpdatedPayload[asset.keyringAccountId] = { + added: [ + ...(assetListUpdatedPayload[asset.keyringAccountId]?.added ?? []), + ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), + ], + removed: [ + ...(assetListUpdatedPayload[asset.keyringAccountId]?.removed ?? []), + ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), + ], + }; + } + + // If no assets were added or removed, don't emit the event. + const isEmptyAccountAssetListUpdatedPayload = Object.values( + assetListUpdatedPayload, + ) + .map((item) => item.added.length + item.removed.length) + .every((item) => item === 0); + + if (!isEmptyAccountAssetListUpdatedPayload) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + } + + // Notify the extension about the changed balances in a single event + const hasChanged = (asset: AssetEntity): boolean => + AssetsService.hasChanged(asset, savedAssets); + + // Emit synthetic zero-balance entries for disappeared assets so clients can + // clear cached balances even when the backend omits zero-balance tokens + // instead of returning them explicitly. + const removedAssetsWithZeroBalance = disappearedAssets.map((asset) => ({ + ...asset, + rawAmount: '0', + uiAmount: '0', + })); + + const assetsToSave = [...assets, ...removedAssetsWithZeroBalance].filter( + (asset) => + isIncremental + ? removedAssetsWithZeroBalance.some( + (removedAsset) => + removedAsset.keyringAccountId === asset.keyringAccountId && + removedAsset.assetType === asset.assetType, + ) || hasChanged(asset) + : true, + ); + // Save assets using repository + await this.#assetsRepository.saveMany(assetsToSave); + + // Broadcast the current snapshot plus synthetic zero-balance removals so the + // client can reconcile both visible assets and cached balances in one pass. + const balancesUpdatedPayload = assetsToSave.reduce< + AccountBalancesUpdatedEvent['params']['balances'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + ...(acc[asset.keyringAccountId] ?? {}), + [asset.assetType]: { + unit: asset.symbol, + amount: asset.uiAmount, + }, + }, + }), + {}, + ); + + // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. + const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) + .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes + .some((count) => count > 0); + + // Only emit the event if some balance was changed. + if (isSomeBalanceChanged) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + } + } + + async getAll(): Promise { + const assetsByAccount = + (await this.#state.getKey('assets')) ?? + {}; + + return Object.values(assetsByAccount).flat(); + } + + /** + * Creates an asset entity with zero balance from a known CAIP-19 asset ID. + * Uses pre-calculated metadata from TokenMetadata. + * + * @param assetId - The CAIP-19 asset ID (e.g., KnownCaip19Id.TrxMainnet). + * @param keyringAccountId - The keyring account ID. + * @returns The asset entity with zero balance. + */ + #createZeroBalanceAsset( + assetId: KnownCaip19Id, + keyringAccountId: string, + ): AssetEntity { + const metadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; + const { chainId } = parseCaipAssetType(assetId); + + return { + assetType: metadata.id, + keyringAccountId, + network: chainId as Network, + symbol: metadata.symbol, + decimals: metadata.decimals, + rawAmount: '0', + uiAmount: '0', + } as AssetEntity; + } + + async getByKeyringAccountId( + keyringAccountId: string, + ): Promise { + const savedAssets = + await this.#assetsRepository.getByAccountId(keyringAccountId); + + /** + * Ensure the special assets are always present whether they have been synced or not. + * These are assets that should be visible to the user even with zero balance. + */ + const missingEssentialAssets: AssetEntity[] = []; + + for (const essentialAssetId of ESSENTIAL_ASSETS) { + const savedAsset = savedAssets.find( + (asset) => (asset.assetType as string) === essentialAssetId, + ); + + if (!savedAsset) { + const zeroBalanceAsset = this.#createZeroBalanceAsset( + essentialAssetId as KnownCaip19Id, + keyringAccountId, + ); + missingEssentialAssets.push(zeroBalanceAsset); + } + } + + return [...savedAssets, ...missingEssentialAssets]; + } + + /** + * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. + * + * @param caipAssetType - The CAIP-19 asset type. + * @returns The fiat ticker. + */ + #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { + if (!AssetsService.isFiat(caipAssetType)) { + throw new Error('Passed caipAssetType is not a fiat asset'); + } + + const fiatTicker = + parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); + + return fiatTicker as FiatTicker; + } + + /** + * Fetches fiat exchange rates and crypto prices for the given assets. + * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. + * + * @param allAssets - Array of all CAIP asset types (both fiat and crypto). + * @returns Promise resolving to fiat exchange rates and crypto prices. + */ + async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ + fiatExchangeRates: Record; + cryptoPrices: Record; + }> { + const cryptoAssets = allAssets.filter( + (asset) => !AssetsService.isFiat(asset), + ); + + const [fiatExchangeRates, cryptoPrices] = await Promise.all([ + this.#priceApiClient.getFiatExchangeRates(), + this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), + ]); + + return { fiatExchangeRates, cryptoPrices }; + } + + /** + * Get the token conversions for a list of asset pairs. + * It caches the results for 1 hour. + * + * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. This is not entirely accurate but it's the + * best we can do with the current API. + * + * @param conversions - The asset pairs to get the conversions for. + * @returns The token conversions. + */ + async getMultipleTokenConversions( + conversions: { from: CaipAssetType; to: CaipAssetType }[], + ): Promise< + Record> + > { + if (conversions.length === 0) { + return {}; + } + + /** + * `from` and `to` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = conversions.flatMap((conversion) => [ + conversion.from, + conversion.to, + ]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + /** + * Now that we have the data, convert the `from`s to `to`s. + * + * We need to handle the following cases: + * 1. `from` and `to` are both fiat + * 2. `from` and `to` are both crypto + * 3. `from` is fiat and `to` is crypto + * 4. `from` is crypto and `to` is fiat + * + * We also need to keep in mind that although `cryptoPrices` are indexed + * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. + * To convert fiat currency symbols to CAIP 19 IDs, we can use the + * `this.#fiatSymbolToCaip19Id` method. + */ + + const result: Record< + CaipAssetType, + Record + > = {}; + + conversions.forEach((conversion) => { + const { from, to } = conversion; + + result[from] ??= {}; + + let fromUsdRate: BigNumber; + let toUsdRate: BigNumber; + + if (AssetsService.isFiat(from)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(from)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); + } + + if (AssetsService.isFiat(to)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(to)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); + } + + if (fromUsdRate.isZero() || toUsdRate.isZero()) { + result[from][to] = null; + return; + } + + const rate = fromUsdRate.dividedBy(toUsdRate).toString(); + + const now = Date.now(); + + result[from][to] = { + rate, + conversionTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + }); + + return result; + } + + /** + * Computes the market data object in the target currency. + * + * @param spotPrice - The spot price of the asset in source currency. + * @param rate - The rate to convert the market data to from source currency to target currency. + * @returns The market data in the target currency. + */ + #computeMarketData( + spotPrice: SpotPrice, + rate: BigNumber, + ): FungibleAssetMarketData { + const marketDataInUsd = pick(spotPrice, [ + 'marketCap', + 'totalVolume', + 'circulatingSupply', + 'allTimeHigh', + 'allTimeLow', + 'pricePercentChange1h', + 'pricePercentChange1d', + 'pricePercentChange7d', + 'pricePercentChange14d', + 'pricePercentChange30d', + 'pricePercentChange200d', + 'pricePercentChange1y', + ]); + + const toCurrency = (value: number | null | undefined): string => { + return value === null || value === undefined + ? '' + : new BigNumber(value).dividedBy(rate).toString(); + }; + + const includeIfDefined = ( + key: string, + value: number | null | undefined, + ): Record => { + return value === null || value === undefined ? {} : { [key]: value }; + }; + + // Variations in percent don't need to be converted, they are independent of the currency + const pricePercentChange = { + ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), + ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), + ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), + ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), + ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), + ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), + ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), + }; + + const marketDataInToCurrency = { + fungible: true, + marketCap: toCurrency(marketDataInUsd.marketCap), + totalVolume: toCurrency(marketDataInUsd.totalVolume), + circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert + allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), + allTimeLow: toCurrency(marketDataInUsd.allTimeLow), + // Add pricePercentChange field only if it has values + ...(Object.keys(pricePercentChange).length > 0 + ? { pricePercentChange } + : {}), + } as FungibleAssetMarketData; + + return marketDataInToCurrency; + } + + async getMultipleTokensMarketData( + assets: { + asset: CaipAssetType; + unit: CaipAssetType; + }[], + ): Promise< + Record> + > { + if (assets.length === 0) { + return {}; + } + + /** + * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + const result: Record< + CaipAssetType, + Record + > = {}; + + assets.forEach((asset) => { + const { asset: assetType, unit } = asset; + + // Skip if we don't have price data for the asset + if (!cryptoPrices[assetType]) { + return; + } + + let unitUsdRate: BigNumber; + + if (AssetsService.isFiat(unit)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; + + if (!fiatExchangeRate) { + return; + } + + unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); + } + + if (unitUsdRate.isZero()) { + return; + } + + // Initialize the nested structure for the asset if it doesn't exist + result[assetType] ??= {}; + + // Store the market data with the unit as the key + result[assetType][unit] = this.#computeMarketData( + cryptoPrices[assetType], + unitUsdRate, + ); + }); + + return result; + } + + /** + * Get historical prices for a token pair by calling the Price API. + * Similar to the Solana snap implementation. + * + * @param from - The asset to get historical prices for. + * @param to - The currency to convert prices to. + * @returns Historical price data with intervals. + */ + async getHistoricalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise<{ + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }> { + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); + assert(toTicker, VsCurrencyParamStruct); + + const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; + + // For each time period, call the Price API to fetch the historical prices + const promises = timePeriodsToFetch.map(async (timePeriod) => + this.#priceApiClient + .getHistoricalPrices({ + assetType: from, + timePeriod, + vsCurrency: toTicker, + }) + // Wrap the response in an object with the time period and the response for easier reducing + .then((response) => ({ + timePeriod, + response, + })) + // Gracefully handle individual errors to avoid breaking the entire operation + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, + error, + ); + return { + timePeriod, + response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + }; + }), + ); + + const wrappedHistoricalPrices = await Promise.all(promises); + + const intervals = wrappedHistoricalPrices.reduce( + (acc, { timePeriod, response }) => { + const iso8601Interval = `P${timePeriod.toUpperCase()}`; + acc[iso8601Interval] = response.prices.map((price) => [ + price[0], + price[1].toString(), + ]); + return acc; + }, + {}, + ); + + const now = Date.now(); + + const result = { + intervals, + updateTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + + return result; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/assets/types.ts b/merged-packages/tron-wallet-snap/src/services/assets/types.ts new file mode 100644 index 00000000..0332b9dc --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/assets/types.ts @@ -0,0 +1,89 @@ +import { CaipAssetTypeStruct, type TrxScope } from '@metamask/keyring-api'; +import { pattern } from '@metamask/superstruct'; + +import { type Network } from '../../constants'; + +export type NativeCaipAssetType = `${Network}/slip44:195`; +export type StakedCaipAssetType = + `${TrxScope}/slip44:195-staked-for-${'energy' | 'bandwidth'}`; +export type ReadyForWithdrawalCaipAssetType = + `${TrxScope}/slip44:195-ready-for-withdrawal`; +export type StakingRewardsCaipAssetType = + `${TrxScope}/slip44:195-staking-rewards`; +export type InLockPeriodCaipAssetType = `${TrxScope}/slip44:195-in-lock-period`; +export type ResourceCaipAssetType = + `${TrxScope}/slip44:${'energy' | 'bandwidth'}`; +export type TokenCaipAssetType = `${TrxScope}/${'trc10' | 'trc20'}:${string}`; +export type NftCaipAssetType = `${TrxScope}/trc721:${string}`; + +/** + * Validates a TRON native CAIP-19 ID (e.g., "tron:728126428/slip44:195") + */ +export const NativeCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/slip44:195$/u, +); + +/** + * Validates a TRON staked CAIP-19 ID (e.g., "tron:728126428/slip44:195-staked-for-energy") + */ +export const StakedCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/slip44:195-staked-for-(energy|bandwidth)$/u, +); + +/** + * Validates a TRON ready-for-withdrawal CAIP-19 ID (e.g., "tron:728126428/slip44:195-ready-for-withdrawal") + */ +export const ReadyForWithdrawalCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/slip44:195-ready-for-withdrawal$/u, +); + +/** + * Validates a TRON staking rewards CAIP-19 ID (e.g., "tron:728126428/slip44:195-staking-rewards") + */ +export const StakingRewardsCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/slip44:195-staking-rewards$/u, +); + +/** + * Validates a TRON in-lock-period CAIP-19 ID (e.g., "tron:728126428/slip44:195-in-lock-period") + */ +export const InLockPeriodCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/slip44:195-in-lock-period$/u, +); + +/** + * Validates a TRON native CAIP-19 ID for resources (e.g., "tron:728126428/energy" or "tron:728126428/bandwidth") + */ +export const ResourceCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/slip44:(energy|bandwidth)$/u, +); + +/** + * Validates a TRON maximum resource CAIP-19 ID (e.g., "tron:728126428/slip44:maximum-energy") + */ +export const MaximumResourceCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/slip44:maximum-(energy|bandwidth)$/u, +); + +/** + * Validates a TRON token CAIP-19 ID (e.g., "tron:728126428/trc10:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t") + */ +export const TokenCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/(trc10|trc20):[a-zA-Z0-9]+$/u, +); + +/** + * Validates a TRON NFT CAIP-19 ID (e.g., "tron:728126428/trc721:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + */ +export const NftCaipAssetTypeStruct = pattern( + CaipAssetTypeStruct, + /^tron:(728126428|3448148188|2494104990)\/trc721:[a-zA-Z0-9]+$/u, +); diff --git a/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts new file mode 100644 index 00000000..33c6d6bf --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/config/ConfigProvider.ts @@ -0,0 +1,238 @@ +/* eslint-disable no-restricted-globals */ +import type { Infer } from '@metamask/superstruct'; +import { + array, + coerce, + create, + enums, + object, + string, +} from '@metamask/superstruct'; +import { Duration } from '@metamask/utils'; + +import { Network, Networks } from '../../constants'; +import { UrlStruct } from '../../validation/structs'; + +const ENVIRONMENT_TO_ACTIVE_NETWORKS = { + production: [Network.Mainnet], + local: [Network.Mainnet], + test: [Network.Mainnet], +}; + +const CommaSeparatedListOfUrlsStruct = coerce( + array(UrlStruct), + string(), + (value: string) => value.split(','), +); + +const EnvStruct = object({ + ENVIRONMENT: enums(['local', 'test', 'production']), + RPC_URL_LIST_MAINNET: CommaSeparatedListOfUrlsStruct, + RPC_URL_LIST_NILE_TESTNET: CommaSeparatedListOfUrlsStruct, + RPC_URL_LIST_SHASTA_TESTNET: CommaSeparatedListOfUrlsStruct, + EXPLORER_MAINNET_BASE_URL: UrlStruct, + EXPLORER_NILE_BASE_URL: UrlStruct, + EXPLORER_SHASTA_BASE_URL: UrlStruct, + PRICE_API_BASE_URL: UrlStruct, + TOKEN_API_BASE_URL: UrlStruct, + STATIC_API_BASE_URL: UrlStruct, + SECURITY_ALERTS_API_BASE_URL: UrlStruct, + NFT_API_BASE_URL: UrlStruct, + LOCAL_API_BASE_URL: string(), + TRONGRID_BASE_URL_MAINNET: UrlStruct, + TRONGRID_BASE_URL_NILE: UrlStruct, + TRONGRID_BASE_URL_SHASTA: UrlStruct, + TRON_HTTP_BASE_URL_MAINNET: UrlStruct, + TRON_HTTP_BASE_URL_NILE: UrlStruct, + TRON_HTTP_BASE_URL_SHASTA: UrlStruct, +}); + +export type Env = Infer; + +export type NetworkConfig = (typeof Networks)[Network] & { + rpcUrls: string[]; + explorerBaseUrl: string; +}; + +export type Config = { + environment: string; + networks: NetworkConfig[]; + activeNetworks: Network[]; + priceApi: { + baseUrl: string; + chunkSize: number; + cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + }; + tokenApi: { + baseUrl: string; + chunkSize: number; + }; + staticApi: { + baseUrl: string; + }; + transactions: { + storageLimit: number; + }; + securityAlertsApi: { + baseUrl: string; + }; + nftApi: { + baseUrl: string; + cacheTtlsMilliseconds: { + listAddressSolanaNfts: number; + getNftMetadata: number; + }; + }; + trongridApi: { + baseUrls: Record; + }; + tronHttpApi: { + baseUrls: Record; + }; +}; + +/** + * A utility class that provides the configuration of the snap. + * + * @example + * const configProvider = new ConfigProvider(); + * const { networks } = configProvider.get(); + * @example + * // You can use utility methods for more advanced manipulations. + * const network = configProvider.getNetworkBy('caip2Id', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + */ +export class ConfigProvider { + readonly #config: Config; + + constructor() { + const environment = this.#parseEnvironment(); + this.#config = this.#buildConfig(environment); + } + + #parseEnvironment(): Env { + const rawEnvironment = { + 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 URLs + TRON_HTTP_BASE_URL_MAINNET: process.env.TRON_HTTP_BASE_URL_MAINNET, + TRON_HTTP_BASE_URL_NILE: process.env.TRON_HTTP_BASE_URL_NILE, + TRON_HTTP_BASE_URL_SHASTA: process.env.TRON_HTTP_BASE_URL_SHASTA, + }; + + // Validate and parse them before returning + return create(rawEnvironment, EnvStruct); + } + + #buildConfig(environment: Env): Config { + return { + environment: environment.ENVIRONMENT, + networks: [ + { + ...Networks[Network.Mainnet], + rpcUrls: environment.RPC_URL_LIST_MAINNET, + explorerBaseUrl: environment.EXPLORER_MAINNET_BASE_URL, + }, + { + ...Networks[Network.Nile], + rpcUrls: environment.RPC_URL_LIST_NILE_TESTNET, + explorerBaseUrl: environment.EXPLORER_NILE_BASE_URL, + }, + { + ...Networks[Network.Shasta], + rpcUrls: environment.RPC_URL_LIST_SHASTA_TESTNET, + explorerBaseUrl: environment.EXPLORER_SHASTA_BASE_URL, + }, + ], + activeNetworks: ENVIRONMENT_TO_ACTIVE_NETWORKS[environment.ENVIRONMENT], + priceApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.PRICE_API_BASE_URL, + chunkSize: 50, + cacheTtlsMilliseconds: { + fiatExchangeRates: Duration.Minute, + spotPrices: Duration.Minute, + historicalPrices: Duration.Minute, + }, + }, + tokenApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.TOKEN_API_BASE_URL, + chunkSize: 50, + }, + staticApi: { + baseUrl: environment.STATIC_API_BASE_URL, + }, + transactions: { + storageLimit: 10, + }, + securityAlertsApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.SECURITY_ALERTS_API_BASE_URL, + }, + nftApi: { + baseUrl: + environment.ENVIRONMENT === 'test' + ? environment.LOCAL_API_BASE_URL + : environment.NFT_API_BASE_URL, + cacheTtlsMilliseconds: { + listAddressSolanaNfts: Duration.Minute, + getNftMetadata: Duration.Minute, + }, + }, + trongridApi: { + baseUrls: { + [Network.Mainnet]: environment.TRONGRID_BASE_URL_MAINNET, + [Network.Nile]: environment.TRONGRID_BASE_URL_NILE, + [Network.Shasta]: environment.TRONGRID_BASE_URL_SHASTA, + }, + }, + tronHttpApi: { + baseUrls: { + [Network.Mainnet]: environment.TRON_HTTP_BASE_URL_MAINNET, + [Network.Nile]: environment.TRON_HTTP_BASE_URL_NILE, + [Network.Shasta]: environment.TRON_HTTP_BASE_URL_SHASTA, + }, + }, + }; + } + + public get(): Config { + return this.#config; + } + + public getNetworkBy(key: keyof NetworkConfig, value: string): NetworkConfig { + const network = this.get().networks.find((item) => item[key] === value); + if (!network) { + throw new Error(`Network ${key} not found`); + } + return network; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/config/index.ts b/merged-packages/tron-wallet-snap/src/services/config/index.ts new file mode 100644 index 00000000..82240384 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/config/index.ts @@ -0,0 +1,2 @@ +export { ConfigProvider } from './ConfigProvider'; +export type { NetworkConfig } from './ConfigProvider'; diff --git a/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.test.ts b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.test.ts new file mode 100644 index 00000000..28f0171d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.test.ts @@ -0,0 +1,587 @@ +import { FeeType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; + +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import { KnownCaip19Id, Network, Networks, ZERO } from '../../constants'; +import type { AssetEntity, ResourceAsset } from '../../entities/assets'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { TronMultichainMethod } from '../../handlers/keyring-types'; +import { getIconUrlForKnownAsset } from '../../ui/confirmation/utils/getIconUrlForKnownAsset'; +import { render as renderConfirmSignTransaction } from '../../ui/confirmation/views/ConfirmSignTransaction/render'; +import { render as renderConfirmTransactionRequest } from '../../ui/confirmation/views/ConfirmTransactionRequest/render'; +import type { AssetsService } from '../assets/AssetsService'; +import type { FeeCalculatorService } from '../send/FeeCalculatorService'; +import type { ComputeFeeResult } from '../send/types'; + +/** + * Subset of State methods. + */ +type MockState = { + getKey: jest.Mock; + setKey: jest.Mock; + setKeyWith: jest.Mock; +}; + +jest.mock( + '../../ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction', + () => ({ + ConfirmSignTransaction: jest.fn().mockReturnValue(''), + }), +); + +jest.mock('../../ui/confirmation/views/ConfirmSignMessage/render', () => ({ + render: jest.fn(), +})); + +jest.mock('../../ui/confirmation/views/ConfirmSignTransaction/render', () => ({ + render: jest.fn(), +})); + +jest.mock( + '../../ui/confirmation/views/ConfirmTransactionRequest/render', + () => ({ + render: jest.fn(), + }), +); + +jest.mock('../../ui/confirmation/utils/getIconUrlForKnownAsset', () => ({ + getIconUrlForKnownAsset: jest.fn(() => 'mock-icon-url'), +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals +const { ConfirmationHandler } = require('./ConfirmationHandler'); + +const mockGetIconUrlForKnownAsset = jest.mocked(getIconUrlForKnownAsset); + +const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + +const mockAccount: TronKeyringAccount = { + id: TEST_ACCOUNT_ID, + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + type: 'tron:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, +}; + +const defaultFees: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: `${Network.Mainnet}/slip44:195`, + amount: '1000000', + fungible: true, + }, + }, +]; + +type WithConfirmationHandlerCallback = (payload: { + handler: InstanceType; + mockSnapClient: jest.Mocked< + Pick< + SnapClient, + | 'getPreferences' + | 'createInterface' + | 'showDialog' + | 'trackTransactionAdded' + | 'trackTransactionApproved' + | 'trackTransactionRejected' + | 'trackError' + > + >; + mockState: MockState; + mockTronWebFactory: jest.Mocked>; + mockTronWeb: { + transactionBuilder: { + withdrawExpireUnfreeze: jest.Mock; + }; + utils: { + deserializeTx: { + deserializeTransaction: jest.Mock; + }; + }; + }; + mockAssetsService: jest.Mocked>; + mockFeeCalculatorService: jest.Mocked< + Pick + >; +}) => Promise | ReturnValue; + +/** + * Wraps tests for ConfirmationHandler by creating a fresh instance with all + * mock dependencies. The callback receives the handler and all mocks. + * + * @param testFunction - The test body receiving the handler and mocks. + * @returns The return value of the callback. + */ +async function withConfirmationHandler( + testFunction: WithConfirmationHandlerCallback, +): Promise { + const mockTronWeb = { + transactionBuilder: { + withdrawExpireUnfreeze: jest + .fn() + // eslint-disable-next-line @typescript-eslint/naming-convention + .mockResolvedValue({ raw_data: {} }), + }, + utils: { + deserializeTx: { + deserializeTransaction: jest.fn(), + }, + }, + }; + + const mockTronWebFactory: jest.Mocked> = + { + createClient: jest.fn().mockReturnValue(mockTronWeb), + }; + + const mockAssetsService: jest.Mocked< + Pick + > = { + getAssetsByAccountId: jest.fn().mockResolvedValue([null, null]), + }; + + const mockFeeCalculatorService: jest.Mocked< + Pick + > = { + computeFee: jest.fn().mockResolvedValue(defaultFees), + }; + + const mockSnapClient: jest.Mocked< + Pick< + SnapClient, + | 'getPreferences' + | 'createInterface' + | 'showDialog' + | 'trackTransactionAdded' + | 'trackTransactionApproved' + | 'trackTransactionRejected' + | 'trackError' + > + > = { + getPreferences: jest.fn().mockResolvedValue({ + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: false, + useExternalPricingData: false, + simulateOnChainActions: false, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }), + createInterface: jest.fn().mockResolvedValue('mock-interface-id'), + showDialog: jest.fn().mockResolvedValue(true), + trackTransactionAdded: jest.fn().mockResolvedValue(undefined), + trackTransactionApproved: jest.fn().mockResolvedValue(undefined), + trackTransactionRejected: jest.fn().mockResolvedValue(undefined), + trackError: jest.fn().mockResolvedValue(undefined), + }; + + const mockState: MockState = { + getKey: jest.fn(), + setKey: jest.fn(), + setKeyWith: jest.fn(), + }; + + const handler = new ConfirmationHandler({ + snapClient: mockSnapClient, + state: mockState, + tronWebFactory: mockTronWebFactory, + assetsService: mockAssetsService, + feeCalculatorService: mockFeeCalculatorService, + }); + + return await testFunction({ + handler, + mockSnapClient, + mockState, + mockTronWebFactory, + mockTronWeb, + mockAssetsService, + mockFeeCalculatorService, + }); +} + +describe('ConfirmationHandler', () => { + describe('confirmClaimUnstakedTrx', () => { + it('returns true when user confirms the dialog', async () => { + await withConfirmationHandler( + async ({ + handler, + mockTronWebFactory, + mockTronWeb, + mockSnapClient, + }) => { + const result = await handler.confirmClaimUnstakedTrx({ + account: mockAccount, + scope: Network.Mainnet, + }); + + expect(result).toBe(true); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + ); + expect( + mockTronWeb.transactionBuilder.withdrawExpireUnfreeze, + ).toHaveBeenCalledWith(mockAccount.address); + expect(mockSnapClient.createInterface).toHaveBeenCalled(); + expect(mockSnapClient.showDialog).toHaveBeenCalledWith( + 'mock-interface-id', + ); + }, + ); + }); + + it('returns false when user rejects the dialog', async () => { + await withConfirmationHandler(async ({ handler, mockSnapClient }) => { + mockSnapClient.showDialog.mockResolvedValue(null); + + const result = await handler.confirmClaimUnstakedTrx({ + account: mockAccount, + scope: Network.Mainnet, + }); + + expect(result).toBe(false); + }); + }); + + it('fetches bandwidth and energy assets for fee computation', async () => { + await withConfirmationHandler(async ({ handler, mockAssetsService }) => { + await handler.confirmClaimUnstakedTrx({ + account: mockAccount, + scope: Network.Mainnet, + }); + + expect(mockAssetsService.getAssetsByAccountId).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + [ + Networks[Network.Mainnet].bandwidth.id, + Networks[Network.Mainnet].energy.id, + ], + ); + }); + }); + + it('uses ZERO when bandwidth and energy assets are null', async () => { + await withConfirmationHandler( + async ({ handler, mockAssetsService, mockFeeCalculatorService }) => { + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + null, + null, + ]); + + await handler.confirmClaimUnstakedTrx({ + account: mockAccount, + scope: Network.Mainnet, + }); + + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope: Network.Mainnet, + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction: { raw_data: {} }, + availableEnergy: ZERO, + availableBandwidth: ZERO, + }); + }, + ); + }); + + it('uses actual asset amounts when bandwidth and energy assets exist', async () => { + await withConfirmationHandler( + async ({ handler, mockAssetsService, mockFeeCalculatorService }) => { + const bandwidthAsset: ResourceAsset = { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: TEST_ACCOUNT_ID, + network: Network.Mainnet, + symbol: 'Bandwidth', + decimals: 0, + rawAmount: '5000', + uiAmount: '5000', + iconUrl: '', + }; + const energyAsset: ResourceAsset = { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: TEST_ACCOUNT_ID, + network: Network.Mainnet, + symbol: 'Energy', + decimals: 0, + rawAmount: '3000', + uiAmount: '3000', + iconUrl: '', + }; + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + bandwidthAsset, + energyAsset, + ]); + + await handler.confirmClaimUnstakedTrx({ + account: mockAccount, + scope: Network.Mainnet, + }); + + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope: Network.Mainnet, + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction: { raw_data: {} }, + availableEnergy: new BigNumber('3000'), + availableBandwidth: new BigNumber('5000'), + }); + }, + ); + }); + + it('throws InternalError when getPreferences fails', async () => { + await withConfirmationHandler(async ({ handler, mockSnapClient }) => { + const error = new Error('preferences unavailable'); + + mockSnapClient.getPreferences.mockRejectedValue(error); + + await expect( + handler.confirmClaimUnstakedTrx({ + account: mockAccount, + scope: Network.Mainnet, + }), + ).rejects.toThrow( + `Failed to retrieve Snap preferences: ${error.message}`, + ); + }); + }); + + it('sets iconUrl on each fee asset via getIconUrlForKnownAsset', async () => { + await withConfirmationHandler( + async ({ handler, mockFeeCalculatorService }) => { + const feeAssetType = `${Network.Mainnet}/slip44:195`; + const feesWithIcon: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: feeAssetType, + amount: '1000000', + fungible: true, + }, + }, + ]; + mockFeeCalculatorService.computeFee.mockResolvedValue(feesWithIcon); + + await handler.confirmClaimUnstakedTrx({ + account: mockAccount, + scope: Network.Mainnet, + }); + + expect(mockGetIconUrlForKnownAsset).toHaveBeenCalledWith( + feeAssetType, + ); + }, + ); + }); + }); + + describe('confirmTransactionRequest', () => { + const mockRenderConfirmTransactionRequest = jest.mocked( + renderConfirmTransactionRequest, + ); + + const mockAsset: AssetEntity = { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: TEST_ACCOUNT_ID, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }; + + const mockTransactionRawData = { + contract: [{ parameter: { value: {} }, type: 'TransferContract' }], + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_bytes: 'abcd', + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_hash: '12345678', + expiration: 1700000000000, + timestamp: 1699999000000, + }; + + const defaultParams = { + scope: Network.Mainnet, + fromAddress: mockAccount.address, + toAddress: 'TDestinationAddress123456789012345', + amount: '1000000', + fees: defaultFees, + asset: mockAsset, + accountType: 'tron:eoa', + origin: 'MetaMask', + transactionRawData: mockTransactionRawData, + }; + + it('returns true and tracks approval when user confirms', async () => { + await withConfirmationHandler(async ({ handler, mockSnapClient }) => { + mockRenderConfirmTransactionRequest.mockResolvedValue(true); + + const result = await handler.confirmTransactionRequest(defaultParams); + + expect(result).toBe(true); + expect(mockSnapClient.trackTransactionAdded).toHaveBeenCalledWith({ + origin: 'MetaMask', + accountType: 'tron:eoa', + chainIdCaip: Network.Mainnet, + }); + expect(mockSnapClient.trackTransactionApproved).toHaveBeenCalledWith({ + origin: 'MetaMask', + accountType: 'tron:eoa', + chainIdCaip: Network.Mainnet, + }); + expect(mockSnapClient.trackTransactionRejected).not.toHaveBeenCalled(); + }); + }); + + it('returns false and tracks rejection when user rejects', async () => { + await withConfirmationHandler(async ({ handler, mockSnapClient }) => { + mockRenderConfirmTransactionRequest.mockResolvedValue(null); + + const result = await handler.confirmTransactionRequest(defaultParams); + + expect(result).toBe(false); + expect(mockSnapClient.trackTransactionRejected).toHaveBeenCalledWith({ + origin: 'MetaMask', + accountType: 'tron:eoa', + chainIdCaip: Network.Mainnet, + }); + expect(mockSnapClient.trackTransactionApproved).not.toHaveBeenCalled(); + }); + }); + + it('passes formatted origin and transactionRawData to render', async () => { + await withConfirmationHandler( + async ({ handler, mockSnapClient, mockState }) => { + mockRenderConfirmTransactionRequest.mockResolvedValue(true); + + await handler.confirmTransactionRequest({ + ...defaultParams, + origin: 'https://example.com', + }); + + expect(mockRenderConfirmTransactionRequest).toHaveBeenCalledWith( + mockSnapClient, + mockState, + expect.objectContaining({ + origin: 'example.com', + transactionRawData: mockTransactionRawData, + }), + ); + }, + ); + }); + + it('clears the interface ID after render completes', async () => { + await withConfirmationHandler(async ({ handler, mockState }) => { + mockRenderConfirmTransactionRequest.mockResolvedValue(true); + + await handler.confirmTransactionRequest(defaultParams); + + expect(mockState.setKey).toHaveBeenCalledWith( + 'mapInterfaceNameToId.confirmTransaction', + null, + ); + }); + }); + + it('logs error but does not throw when clearing interface ID fails', async () => { + await withConfirmationHandler(async ({ handler, mockState }) => { + mockRenderConfirmTransactionRequest.mockResolvedValue(true); + mockState.setKey.mockRejectedValue(new Error('state write failed')); + + const result = await handler.confirmTransactionRequest(defaultParams); + + expect(result).toBe(true); + expect(mockState.setKey).toHaveBeenCalledWith( + 'mapInterfaceNameToId.confirmTransaction', + null, + ); + }); + }); + + it('tracks the error', async () => { + await withConfirmationHandler( + async ({ handler, mockSnapClient, mockState }) => { + const error = new Error('state write failed'); + + mockRenderConfirmTransactionRequest.mockResolvedValue(true); + mockState.setKey.mockRejectedValue(error); + + await handler.confirmTransactionRequest(defaultParams); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); + }, + ); + }); + }); + + describe('handleKeyringRequest', () => { + const mockRenderConfirmSignTransaction = jest.mocked( + renderConfirmSignTransaction, + ); + + const rawData = { + contract: [{ parameter: { value: {} }, type: 'TransferContract' }], + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_bytes: 'abcd', + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_hash: '12345678', + expiration: 1_700_000_000_000, + timestamp: 1_699_999_000_000, + }; + + const request = { + id: '00000000-0000-4000-8000-000000000001', + origin: 'https://test-origin.com', + account: TEST_ACCOUNT_ID, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: mockAccount.address, + transaction: { + rawDataHex: 'original-raw-data-hex', + type: 'TransferContract', + }, + }, + }, + }; + + it('passes deserialized raw data when rendering signTransaction confirmation', async () => { + await withConfirmationHandler(async ({ handler, mockTronWeb }) => { + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue( + rawData, + ); + mockRenderConfirmSignTransaction.mockResolvedValue(true); + + await handler.handleKeyringRequest({ + request, + account: mockAccount, + }); + + expect( + mockTronWeb.utils.deserializeTx.deserializeTransaction, + ).toHaveBeenCalledWith( + request.request.params.transaction.type, + request.request.params.transaction.rawDataHex, + ); + expect(mockRenderConfirmSignTransaction).toHaveBeenCalledWith( + request, + mockAccount, + rawData, + ); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts new file mode 100644 index 00000000..73c4c5e2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts @@ -0,0 +1,294 @@ +import { InternalError } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; +import type { Types } from 'tronweb'; + +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import { type Network, Networks, ZERO } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { TronMultichainMethod } from '../../handlers/keyring-types'; +import { TRX_IMAGE_SVG } from '../../static/tron-logo'; +import { FetchStatus } from '../../types/snap'; +import { getIconUrlForKnownAsset } from '../../ui/confirmation/utils/getIconUrlForKnownAsset'; +import { render as renderConfirmSignMessage } from '../../ui/confirmation/views/ConfirmSignMessage/render'; +import { ConfirmSignTransaction } from '../../ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction'; +import { render as renderConfirmSignTransaction } from '../../ui/confirmation/views/ConfirmSignTransaction/render'; +import { + CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME, + type ConfirmSignTransactionContext, +} from '../../ui/confirmation/views/ConfirmSignTransaction/types'; +import { render as renderConfirmTransactionRequest } from '../../ui/confirmation/views/ConfirmTransactionRequest/render'; +import { CONFIRM_TRANSACTION_INTERFACE_NAME } from '../../ui/confirmation/views/ConfirmTransactionRequest/types'; +import { formatOrigin } from '../../utils/formatOrigin'; +import type { ILogger } from '../../utils/logger'; +import logger, { createPrefixedLogger } from '../../utils/logger'; +import { + SignTransactionRequestStruct, + type TronWalletKeyringRequest, +} from '../../validation/structs'; +import { assertTransactionStructure } from '../../validation/transaction'; +import type { AssetsService } from '../assets/AssetsService'; +import type { FeeCalculatorService } from '../send/FeeCalculatorService'; +import type { ComputeFeeResult } from '../send/types'; +import type { State, UnencryptedStateValue } from '../state/State'; + +export class ConfirmationHandler { + readonly #logger: ILogger; + + readonly #snapClient: SnapClient; + + readonly #state: State; + + readonly #tronWebFactory: TronWebFactory; + + readonly #assetsService: AssetsService; + + readonly #feeCalculatorService: FeeCalculatorService; + + constructor({ + snapClient, + state, + tronWebFactory, + assetsService, + feeCalculatorService, + }: { + snapClient: SnapClient; + state: State; + tronWebFactory: TronWebFactory; + assetsService: AssetsService; + feeCalculatorService: FeeCalculatorService; + }) { + this.#logger = createPrefixedLogger(logger, '[🔑 ConfirmationHandler]'); + this.#snapClient = snapClient; + this.#state = state; + this.#tronWebFactory = tronWebFactory; + this.#assetsService = assetsService; + this.#feeCalculatorService = feeCalculatorService; + } + + async #clearInterfaceId(interfaceName: string): Promise { + try { + await this.#state.setKey(`mapInterfaceNameToId.${interfaceName}`, null); + } catch (error) { + await this.#snapClient.trackError(error as Error); + this.#logger.error({ error }, 'Failed to clear interface ID'); + } + } + + async handleKeyringRequest({ + request, + account, + }: { + request: TronWalletKeyringRequest; + account: TronKeyringAccount; + }): Promise { + this.#logger.info('Handling keyring request', { + request, + account, + }); + + const { method } = request.request; + + // Handle different request types + switch (method as TronMultichainMethod) { + case TronMultichainMethod.SignMessage: { + return this.#handleSignMessageRequest(request, account); + } + case TronMultichainMethod.SignTransaction: { + return this.#handleSignTransactionRequest(request, account); + } + default: + this.#logger.warn('Unhandled keyring request method', { method }); + throw new Error(`Unhandled keyring request method: ${method}`); + } + } + + async #handleSignMessageRequest( + request: TronWalletKeyringRequest, + account: TronKeyringAccount, + ): Promise { + const result = await renderConfirmSignMessage(request, account); + return result === true; + } + + async #handleSignTransactionRequest( + request: TronWalletKeyringRequest, + account: TronKeyringAccount, + ): Promise { + assert(request.request.params, SignTransactionRequestStruct); + + const { + scope, + request: { + params: { + transaction: { rawDataHex, type }, + }, + }, + } = request; + + // Create a TronWeb instance for transaction deserialization + const tronWeb = this.#tronWebFactory.createClient(scope); + + // Rebuild the transaction from hex (same logic as clientRequest handler) + const rawData = tronWeb.utils.deserializeTx.deserializeTransaction( + type, + rawDataHex, + ); + assertTransactionStructure(rawData); + + const result = await renderConfirmSignTransaction( + request, + account, + rawData, + ); + + await this.#clearInterfaceId(CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME); + + return result === true; + } + + async confirmTransactionRequest({ + scope, + fromAddress, + toAddress, + amount, + fees, + asset, + accountType, + origin, + transactionRawData, + }: { + scope: Network; + fromAddress: string; + toAddress: string; + amount: string; + fees: ComputeFeeResult; + asset: AssetEntity; + accountType: string; + origin: string; + transactionRawData: Types.Transaction['raw_data']; + }): Promise { + // Track Transaction Added event + await this.#snapClient.trackTransactionAdded({ + origin, + accountType, + chainIdCaip: scope, + }); + + const result = await renderConfirmTransactionRequest( + this.#snapClient, + this.#state, + { + scope, + fromAddress, + toAddress, + amount, + fees, + asset, + origin: formatOrigin(origin), + accountType, + transactionRawData, + }, + ); + + await this.#clearInterfaceId(CONFIRM_TRANSACTION_INTERFACE_NAME); + + // Track Transaction Rejected event if user rejects + if (result === true) { + await this.#snapClient.trackTransactionApproved({ + origin, + accountType, + chainIdCaip: scope, + }); + } else { + await this.#snapClient.trackTransactionRejected({ + origin, + accountType, + chainIdCaip: scope, + }); + } + + return result === true; + } + + /** + * Shows a confirmation dialog for claiming unstaked TRX, + * reusing the ConfirmSignTransaction UI component. + * + * Builds an unsigned transaction to estimate fees, then presents + * the user with an approval dialog before any signing occurs. + * + * @param params - The parameters for the confirmation. + * @param params.account - The account claiming the unstaked TRX. + * @param params.scope - The network scope. + * @returns True if the user confirmed, false otherwise. + */ + async confirmClaimUnstakedTrx({ + account, + scope, + }: { + account: TronKeyringAccount; + scope: Network; + }): Promise { + const tronWeb = this.#tronWebFactory.createClient(scope); + const unsignedTx = await tronWeb.transactionBuilder.withdrawExpireUnfreeze( + account.address, + ); + + const [bandwidthAsset, energyAsset] = + await this.#assetsService.getAssetsByAccountId(account.id, [ + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); + + const availableEnergy = energyAsset + ? new BigNumber(energyAsset.rawAmount) + : ZERO; + const availableBandwidth = bandwidthAsset + ? new BigNumber(bandwidthAsset.rawAmount) + : ZERO; + + const fees = await this.#feeCalculatorService.computeFee({ + scope, + transaction: unsignedTx, + availableEnergy, + availableBandwidth, + }); + + fees.forEach((fee) => { + fee.asset.iconUrl = getIconUrlForKnownAsset(fee.asset.type); + }); + + let preferences; + try { + preferences = await this.#snapClient.getPreferences(); + } catch (error) { + throw new InternalError( + `Failed to retrieve Snap preferences: ${(error as Error).message}`, + ) as Error; + } + + const context: ConfirmSignTransactionContext = { + scope, + account, + transaction: { rawDataHex: '', type: '' }, + origin: 'MetaMask', + preferences, + networkImage: TRX_IMAGE_SVG, + scan: null, + scanFetchStatus: FetchStatus.Fetched, + tokenPrices: {}, + tokenPricesFetchStatus: FetchStatus.Fetched, + fees, + feesFetchStatus: FetchStatus.Fetched, + }; + + const ui = ConfirmSignTransaction({ context }); + const id = await this.#snapClient.createInterface(ui, context); + + const result = await this.#snapClient.showDialog(id); + return result === true; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts new file mode 100644 index 00000000..2655c4be --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.test.ts @@ -0,0 +1,2028 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { FeeType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; + +import { FeeCalculatorService } from './FeeCalculatorService'; +import { Network, ZERO } from '../../constants'; +import { mockLogger } from '../../utils/mockLogger'; +import nativeTransferMock from '../transactions/mocks/trongrid/account-transactions/native-transfer.json'; +import trc10TransferMock from '../transactions/mocks/trongrid/account-transactions/trc10-transfer.json'; +import trc20TransferMock from '../transactions/mocks/trongrid/account-transactions/trc20-transfer.json'; + +const mockTronWebFactory = { + createClient: jest.fn(), +} as any; + +const mockTrongridApiClient = { + getChainParameters: jest.fn(), + getAccountInfoByAddress: jest.fn(), +} as any; + +const mockTronHttpClient = { + triggerConstantContract: jest.fn(), + getContract: jest.fn(), + getAccountResources: jest.fn(), +} as any; + +// Helper to get transaction examples in the expected format +const getTransactionExample = (type: 'native' | 'trc10' | 'trc20'): any => { + let mockData; + switch (type) { + case 'native': + mockData = nativeTransferMock; + break; + case 'trc10': + mockData = trc10TransferMock; + break; + case 'trc20': + mockData = trc20TransferMock; + break; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unknown transaction type: ${type}`); + } + + // Extract the transaction structure that matches the expected Transaction type + return { + visible: false, + txID: mockData.txID, + raw_data_hex: mockData.raw_data_hex, + raw_data: mockData.raw_data, + }; +}; + +// Helper to create a large transaction by modifying the TRC20 example +const createLargeTransaction = (): any => { + const baseTransaction = getTransactionExample('trc20'); + // Modify the data field to be much larger to simulate bandwidth issues + const largeData = 'b'.repeat(2000); + + return { + ...baseTransaction, + raw_data: { + ...baseTransaction.raw_data, + contract: [ + { + ...baseTransaction.raw_data.contract[0], + parameter: { + ...baseTransaction.raw_data.contract[0].parameter, + value: { + ...baseTransaction.raw_data.contract[0].parameter.value, + data: largeData, + }, + }, + }, + ], + }, + }; +}; + +describe('FeeCalculatorService', () => { + let feeCalculatorService: FeeCalculatorService; + let mockTronWebClient: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockTronWebClient = { + trx: { + getChainParameters: jest.fn(), + }, + address: { + fromHex: jest.fn((hexAddress) => `base58_${hexAddress}`), + }, + transactionBuilder: { + triggerConstantContract: jest.fn(), + }, + }; + + mockTronWebFactory.createClient.mockReturnValue(mockTronWebClient); + + // Default: no energy sharing (user pays all) + mockTronHttpClient.getContract.mockResolvedValue(null); + + // Default: deployer energy fetch fails + mockTronHttpClient.getAccountResources.mockRejectedValue( + new Error('Account not found'), + ); + + feeCalculatorService = new FeeCalculatorService({ + logger: mockLogger, + trongridApiClient: mockTrongridApiClient, + tronHttpClient: mockTronHttpClient, + }); + }); + + describe('computeFee', () => { + beforeEach(() => { + // Mock chain parameters response + mockTrongridApiClient.getChainParameters.mockResolvedValue([ + { key: 'getTransactionFee', value: 1000 }, + { key: 'getEnergyFee', value: 100 }, + ]); + }); + + describe('TransferContract scenarios (no energy needed)', () => { + it('has enough bandwidth', async () => { + const transaction = getTransactionExample('native'); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); // More than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // TRX is always first (even if 0), then bandwidth consumption + // Native transfer: 132 bytes (raw_data_hex / 2) + 134 = 266 bytes + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }); + + it('not enough bandwidth', async () => { + const transaction = getTransactionExample('native'); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(100); // Less than needed (266) + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // New behavior: When there's not enough bandwidth, ALL bandwidth is paid in TRX + // Bandwidth needed: 266 bytes + // No bandwidth consumed (since we don't have enough) + // TRX cost: 266 * 1000 SUN = 266,000 SUN = 0.266 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0.266', + fungible: true, + }, + }, + ]); + }); + }); + + describe('System contract scenarios (no energy needed)', () => { + // Helper to create a mock transaction with a specific contract type + const createSystemContractTransaction = (contractType: string): any => { + const baseTransaction = getTransactionExample('native'); + return { + ...baseTransaction, + raw_data: { + ...baseTransaction.raw_data, + contract: [ + { + parameter: { + value: { + owner_address: '41a7d8a35b260395c14aa456297662092ba3b76fc0', + frozen_balance: 1000000, + resource: 'ENERGY', + }, + type_url: `type.googleapis.com/protocol.${contractType}`, + }, + type: contractType, + }, + ], + }, + }; + }; + + it.each([ + 'FreezeBalanceV2Contract', + 'UnfreezeBalanceV2Contract', + 'VoteWitnessContract', + 'DelegateResourceContract', + 'UnDelegateResourceContract', + 'WithdrawExpireUnfreezeContract', + 'AccountCreateContract', + 'AccountPermissionUpdateContract', + ])('returns zero energy for %s', async (contractType) => { + const transaction = createSystemContractTransaction(contractType); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // System contracts should only have TRX (0) and bandwidth, no energy + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }); + + it('charges bandwidth in TRX when not enough bandwidth for system contract', async () => { + const transaction = createSystemContractTransaction( + 'FreezeBalanceV2Contract', + ); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(100); // Less than needed (266) + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // System contracts should only pay for bandwidth in TRX, no energy cost + // Bandwidth needed: 266 bytes + // TRX cost: 266 * 1000 SUN = 266,000 SUN = 0.266 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0.266', + fungible: true, + }, + }, + ]); + }); + }); + + describe('TriggerSmartContract scenarios (energy needed)', () => { + beforeEach(() => { + // Mock energy calculation for smart contracts + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 130000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + }); + + it('has enough bandwidth + has enough energy', async () => { + const transaction = getTransactionExample('trc20'); + + const availableEnergy = BigNumber(100000); // More than needed (100000 fallback) + const availableBandwidth = BigNumber(2000000); // More than needed for TRC20 transaction + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Energy: 130000 (hardcoded), available: 100000 + // Energy consumed: 100000, overage: 30000 + // TRX cost: 30000 * 100 SUN = 3,000,000 SUN = 3 TRX + // Bandwidth: 211 bytes (raw_data_hex / 2) + 134 = 345 bytes + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '3', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '100000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + + it('has enough bandwidth + not enough energy', async () => { + const transaction = getTransactionExample('trc20'); + + const availableEnergy = BigNumber(30000); // Less than needed (50000) + const availableBandwidth = BigNumber(2000000); // More than needed for TRC20 transaction + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should have TRX first, then bandwidth consumption and energy + // Energy: 130000 (hardcoded), available: 30000 + // Energy consumed: 30000, overage: 100000 + // TRX cost: 100000 * 100 SUN = 10,000,000 SUN = 10 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '10', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '30000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + + it('not enough bandwidth + has enough energy', async () => { + const largeTransaction = createLargeTransaction(); + + const availableEnergy = BigNumber(100000); // More than needed (50000) + const availableBandwidth = BigNumber(100); // Less than needed (345 bytes actual) + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: largeTransaction, + availableEnergy, + availableBandwidth, + }); + + // Should have TRX first, then energy consumption + // Energy: 130000 (hardcoded), available: 100000 + // Energy consumed: 100000, overage: 30000 + // Energy TRX cost: 30000 * 100 SUN = 3,000,000 SUN = 3 TRX + // Note: raw_data_hex doesn't change when we modify the data field, + // so bandwidth is still 345 bytes (not the larger theoretical size) + // New behavior: Not enough bandwidth (100 < 345), so ALL bandwidth is paid in TRX + // Bandwidth TRX cost: 345 * 1000 SUN = 345,000 SUN = 0.345 TRX + // Total TRX cost: 3.345 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '3.345', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '100000', + fungible: true, + }, + }, + ]); + }); + + it('not enough bandwidth + not enough energy', async () => { + const largeTransaction = createLargeTransaction(); + + const availableEnergy = BigNumber(30000); // Less than needed (50000) + const availableBandwidth = BigNumber(100); // Less than needed (345) + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: largeTransaction, + availableEnergy, + availableBandwidth, + }); + + // Should have TRX first, then energy consumption + // Energy: 130000 (hardcoded), available: 30000 + // Energy consumed: 30000, overage: 100000 → 10 TRX + // New behavior: Not enough bandwidth (100 < 345), so ALL bandwidth is paid in TRX + // Bandwidth TRX cost: 345 * 1000 SUN = 345,000 SUN = 0.345 TRX + // Total: 10.345 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '10.345', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '30000', + fungible: true, + }, + }, + ]); + }); + }); + + describe('Edge cases and error handling', () => { + it('filters out zero amount fees', async () => { + const transaction = getTransactionExample('native'); + const availableEnergy = ZERO; + const availableBandwidth = ZERO; + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should only have TRX cost, no zero amount fees + // 266 bandwidth * 1000 SUN = 266,000 SUN = 0.266 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0.266', + fungible: true, + }, + }, + ]); + }); + + it('handles different networks correctly', async () => { + const transaction = getTransactionExample('native'); + const availableEnergy = BigNumber(100000); + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Shasta, + transaction, + availableEnergy, + availableBandwidth, + }); + + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:2494104990/slip44:195', + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:2494104990/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }); + + it('uses fallback values when chain parameters are missing', async () => { + mockTrongridApiClient.getChainParameters.mockResolvedValue([]); + + const transaction = getTransactionExample('trc20'); + + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 130000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + const availableEnergy = BigNumber(100000); + const availableBandwidth = BigNumber(2000000); // Ensure enough bandwidth + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should use fallback values: energyFee=100, bandwidthFee=1000 + // Energy: 130000 (hardcoded), available: 100000 + // Energy consumed: 100000, overage: 30000 + // TRX cost: 30000 * 100 SUN = 3,000,000 SUN = 3 TRX + // Bandwidth: 345 bytes consumed + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '3', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '100000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + + it('handles very large transactions', async () => { + const veryLargeTransaction = createLargeTransaction(); + // Make it even larger for this test + veryLargeTransaction.raw_data.contract[0].parameter.value.data = + 'b'.repeat(10000); + + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 130000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + const availableEnergy = BigNumber(100000); + const availableBandwidth = BigNumber(100); // Less than needed (345) + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction: veryLargeTransaction, + availableEnergy, + availableBandwidth, + }); + + // Energy: 130000 (hardcoded), available: 100000 + // Energy consumed: 100000, overage: 30000 + // Energy TRX cost: 30000 * 100 = 3,000,000 SUN = 3 TRX + // Note: Modifying the data field doesn't update raw_data_hex, + // so bandwidth is still calculated from the original hex (345 bytes) + // New behavior: Not enough bandwidth (100 < 345), so ALL bandwidth is paid in TRX + // Bandwidth TRX cost: 345 * 1000 SUN = 345,000 SUN = 0.345 TRX + // Total TRX cost: 3.345 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '3.345', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '100000', + fungible: true, + }, + }, + ]); + }); + + it('handles exact resource matches', async () => { + const transaction = getTransactionExample('native'); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(266); // Exact match for native transaction + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }); + }); + + describe('Account activation fee scenarios', () => { + it('adds 1 TRX activation fee when recipient account is not activated', async () => { + // Mock the account check to throw (account not found) + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + + const transaction = getTransactionExample('native'); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); // More than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should have TRX first (1 TRX activation fee), then bandwidth consumption + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '1', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }); + + it('adds activation fee to existing TRX cost when recipient is not activated', async () => { + // Mock the account check to throw (account not found) + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + + const transaction = getTransactionExample('native'); + const availableEnergy = ZERO; + const availableBandwidth = ZERO; // Not enough bandwidth, triggers TRX cost + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should have TRX cost for bandwidth (0.266) + activation fee (1) = 1.266 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '1.266', + fungible: true, + }, + }, + ]); + }); + + it('does not add activation fee when recipient account is already activated', async () => { + // Mock the account check to succeed (account exists) + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue({ + address: 'TExistingActiveAddress123456789012', + balance: 1000000, + }); + + const transaction = getTransactionExample('native'); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); // More than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should have TRX first (0 since no activation fee), then bandwidth consumption + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }); + + it('does not check activation for TRC20 transfers (no TransferContract)', async () => { + // Mock energy calculation for smart contracts + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 130000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(130000); // Enough energy + const availableBandwidth = BigNumber(2000000); // More than needed + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should have TRX first (0), then energy and bandwidth consumption + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '130000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + + // getAccountInfoByAddress should not have been called for TRC20 + expect( + mockTrongridApiClient.getAccountInfoByAddress, + ).not.toHaveBeenCalled(); + }); + }); + + describe('FeeLimit fallback scenarios', () => { + it('uses feeLimit to calculate fallback energy when simulation fails', async () => { + // Simulate triggerConstantContract failing + mockTronHttpClient.triggerConstantContract.mockRejectedValue( + new Error('Simulation failed'), + ); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(50000); + const availableBandwidth = BigNumber(2000000); + + // feeLimit of 10,000,000 SUN with energy price 100 SUN = 100,000 max energy + const feeLimit = 10_000_000; + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + feeLimit, + }); + + // Energy from feeLimit: 10,000,000 / 100 = 100,000 energy + // Available energy: 50,000 + // Energy consumed: 50,000, overage: 50,000 + // TRX cost: 50,000 * 100 SUN = 5,000,000 SUN = 5 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '5', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '50000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + + it('uses default 130000 fallback when simulation fails and no feeLimit provided', async () => { + // Simulate triggerConstantContract failing + mockTronHttpClient.triggerConstantContract.mockRejectedValue( + new Error('Simulation failed'), + ); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(30000); + const availableBandwidth = BigNumber(2000000); + + // No feeLimit provided + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should use default 130,000 fallback energy + // Available energy: 30,000 + // Energy consumed: 30,000, overage: 100,000 + // TRX cost: 100,000 * 100 SUN = 10,000,000 SUN = 10 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '10', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '30000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + + it('ignores feeLimit when simulation succeeds', async () => { + // Simulation succeeds with energy_used + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 65000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(50000); + const availableBandwidth = BigNumber(2000000); + + // Provide a large feeLimit that would calculate to 200,000 energy + const feeLimit = 20_000_000; + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + feeLimit, + }); + + // Should use the actual simulation result (65,000), not the feeLimit calculation + // Available energy: 50,000 + // Energy consumed: 50,000, overage: 15,000 + // TRX cost: 15,000 * 100 SUN = 1,500,000 SUN = 1.5 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '1.5', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '50000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + + it('handles energy_used of zero correctly without falling back', async () => { + // Simulation returns energy_used: 0 (legitimate zero energy consumption) + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 0, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(50000); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Should use the actual simulation result (0 energy), not fall back + // Energy consumed: 0 from available, no overage + // TRX is 0 but still first + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + + it('uses feeLimit fallback when simulation returns no energy_used', async () => { + // Simulation returns empty/no energy_used + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(20000); + const availableBandwidth = BigNumber(2000000); + + // feeLimit of 5,000,000 SUN with energy price 100 SUN = 50,000 max energy + const feeLimit = 5_000_000; + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + feeLimit, + }); + + // Energy from feeLimit: 5,000,000 / 100 = 50,000 energy + // Available energy: 20,000 + // Energy consumed: 20,000, overage: 30,000 + // TRX cost: 30,000 * 100 SUN = 3,000,000 SUN = 3 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '3', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '20000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + + it('uses fallback energy price (420 SUN) when chain parameters are missing', async () => { + // Simulate triggerConstantContract failing + mockTronHttpClient.triggerConstantContract.mockRejectedValue( + new Error('Simulation failed'), + ); + + // Chain parameters missing getEnergyFee + mockTrongridApiClient.getChainParameters.mockResolvedValue([ + { key: 'getTransactionFee', value: 1000 }, + // No getEnergyFee + ]); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + // feeLimit of 4,200,000 SUN with fallback energy price 420 SUN = 10,000 max energy + const feeLimit = 4_200_000; + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + feeLimit, + }); + + // Energy from feeLimit: 4,200,000 / 420 = 10,000 energy + // Available energy: 0 + // Energy consumed: 0, overage: 10,000 + // TRX cost: 10,000 * 420 SUN / 1,000,000 = 4.2 TRX + // But getEnergyFee is missing from chain params, so it uses fallback 100 SUN + // TRX cost: 10,000 * 100 SUN / 1,000,000 = 1 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '1', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '345', + fungible: true, + }, + }, + ]); + }); + }); + + describe('Energy sharing mechanism', () => { + beforeEach(() => { + // Mock chain parameters response + mockTrongridApiClient.getChainParameters.mockResolvedValue([ + { key: 'getTransactionFee', value: 1000 }, + { key: 'getEnergyFee', value: 100 }, + ]); + }); + + it('applies 50% energy sharing when consume_user_resource_percent = 50', async () => { + // Total energy: 80000, user pays 50% = 40000 + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 50, + origin_energy_limit: 100000, + }); + + mockTronHttpClient.getAccountResources.mockResolvedValue({ + EnergyLimit: 100000, + EnergyUsed: 0, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 50% of 80000 = 40000 energy + // TRX cost: 40000 * 100 SUN = 4,000,000 SUN = 4 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '4', + fungible: true, + }, + }); + }); + + it('caps deployer subsidy at origin_energy_limit', async () => { + // Total: 100000, user 10% = 10000, deployer 90% = 90000 but capped at 50000 + // User actual: 100000 - 50000 = 50000 + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 100000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 10, // User pays 10%, deployer 90% + origin_energy_limit: 50000, // But capped at 50000 + }); + + mockTronHttpClient.getAccountResources.mockResolvedValue({ + EnergyLimit: 200000, + EnergyUsed: 0, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 50000 energy (100000 - 50000 cap) + // TRX cost: 50000 * 100 SUN = 5,000,000 SUN = 5 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '5', + fungible: true, + }, + }); + }); + + it('user pays 100% when getContract returns null', async () => { + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue(null); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 100% of 80000 energy + // TRX cost: 80000 * 100 SUN = 8,000,000 SUN = 8 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + }); + + it('user pays 100% when consume_user_resource_percent is missing', async () => { + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + // No consume_user_resource_percent + origin_energy_limit: 100000, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 100% (default when consume_user_resource_percent is missing) + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + }); + + it('user pays 100% when origin_energy_limit is missing', async () => { + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + consume_user_resource_percent: 50, + // No origin_energy_limit + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 100% (no subsidy when origin_energy_limit is 0/missing) + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + }); + + it('user pays 100% when getContract throws error', async () => { + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockRejectedValue( + new Error('Network error'), + ); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 100% (fallback on error) + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + }); + + it('user pays 0 energy when consume_user_resource_percent = 0', async () => { + // Deployer pays all energy (up to origin_energy_limit) + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 0, + origin_energy_limit: 100000, + }); + + mockTronHttpClient.getAccountResources.mockResolvedValue({ + EnergyLimit: 100000, + EnergyUsed: 0, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 0 energy, only bandwidth + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '0', + fungible: true, + }, + }); + }); + + it('user pays 100% when consume_user_resource_percent = 100', async () => { + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + consume_user_resource_percent: 100, + origin_energy_limit: 50000, // origin_energy_limit is irrelevant when user pays 100% + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 100% + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + }); + + it('applies energy sharing before deducting available staked energy', async () => { + // Total: 100000, user pays 50% = 50000 + // User has 30000 staked energy + // User burns TRX for remaining 20000 + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 100000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 50, + origin_energy_limit: 100000, + }); + + mockTronHttpClient.getAccountResources.mockResolvedValue({ + EnergyLimit: 100000, + EnergyUsed: 0, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(30000); // User has 30000 staked + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User needs 50000 (after 50% sharing) + // User has 30000 staked, burns TRX for 20000 + // TRX cost: 20000 * 100 SUN = 2,000,000 SUN = 2 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '2', + fungible: true, + }, + }); + + // Energy consumed from staking + expect(result[1]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + amount: '30000', + fungible: true, + }, + }); + }); + + describe('deployer available energy constraint', () => { + it('caps deployer subsidy at deployer available energy when lower than origin_energy_limit', async () => { + // Total: 100000, user 10%, deployer theoretical 90% = 90000 + // origin_energy_limit: 100000 (not the limiting factor) + // deployer available energy: 30000 (this is the limiting factor) + // User pays: 100000 - 30000 = 70000 + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 100000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 10, + origin_energy_limit: 100000, + }); + + mockTronHttpClient.getAccountResources.mockResolvedValue({ + EnergyLimit: 50000, + EnergyUsed: 20000, // Available = 30000 + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 70000 energy (100000 - 30000 deployer available) + // TRX cost: 70000 * 100 SUN = 7,000,000 SUN = 7 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '7', + fungible: true, + }, + }); + }); + + it('uses origin_energy_limit when deployer has more available energy', async () => { + // Total: 100000, user 10%, deployer theoretical 90% = 90000 + // origin_energy_limit: 50000 (this is the limiting factor) + // deployer available energy: 200000 (not the limiting factor) + // User pays: 100000 - 50000 = 50000 + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 100000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 10, + origin_energy_limit: 50000, + }); + + mockTronHttpClient.getAccountResources.mockResolvedValue({ + EnergyLimit: 200000, + EnergyUsed: 0, // Available = 200000 + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // User pays 50000 energy (capped by origin_energy_limit, not deployer available) + // TRX cost: 50000 * 100 SUN = 5,000,000 SUN = 5 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '5', + fungible: true, + }, + }); + }); + + it('user pays 100% when origin_address is missing', async () => { + // Contract info without origin_address + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + // No origin_address + consume_user_resource_percent: 50, + origin_energy_limit: 100000, + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Can't verify deployer energy, so user pays 100% + // TRX cost: 80000 * 100 SUN = 8,000,000 SUN = 8 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + + // Verify getAccountResources was not called + expect(mockTronHttpClient.getAccountResources).not.toHaveBeenCalled(); + }); + + it('user pays 100% when deployer account fetch fails', async () => { + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 50, + origin_energy_limit: 100000, + }); + + // Deployer account fetch fails + mockTronHttpClient.getAccountResources.mockRejectedValue( + new Error('Network error'), + ); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Can't verify deployer energy, so user pays 100% + // TRX cost: 80000 * 100 SUN = 8,000,000 SUN = 8 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + }); + + it('handles deployer with zero available energy', async () => { + // Deployer has exhausted all their energy + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 0, // Deployer claims to pay all + origin_energy_limit: 100000, + }); + + mockTronHttpClient.getAccountResources.mockResolvedValue({ + EnergyLimit: 10000, + EnergyUsed: 10000, // Available = 0 + }); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Deployer claims 100% but has 0 energy, so user pays 100% + // TRX cost: 80000 * 100 SUN = 8,000,000 SUN = 8 TRX + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + }); + + it('handles deployer account with empty resources response', async () => { + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 80000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + mockTronHttpClient.getContract.mockResolvedValue({ + origin_address: 'TDeployerAddress123456789012345', + consume_user_resource_percent: 50, + origin_energy_limit: 100000, + }); + + // Account doesn't exist (returns empty/partial data) + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const transaction = getTransactionExample('trc20'); + const availableEnergy = BigNumber(0); + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Deployer has 0 available energy (no EnergyLimit field) + // User pays 80000 - 0 = 80000 (all of it) + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '8', + fungible: true, + }, + }); + }); + }); + }); + + describe('Memo fee scenarios', () => { + // Helper to add a memo (raw_data.data) to a transaction + const addMemoToTransaction = ( + transaction: any, + memoHex: string, + ): any => ({ + ...transaction, + raw_data: { + ...transaction.raw_data, + data: memoHex, + }, + }); + + it('adds 1 TRX memo fee when transaction has a memo and enough bandwidth', async () => { + const transaction = addMemoToTransaction( + getTransactionExample('native'), + '48656c6c6f', // "Hello" in hex + ); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // TRX: 1 (memo fee), bandwidth consumed + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '1', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }); + + it('adds 1 TRX memo fee on top of bandwidth cost when not enough bandwidth', async () => { + const transaction = addMemoToTransaction( + getTransactionExample('native'), + '48656c6c6f', + ); + const availableEnergy = ZERO; + const availableBandwidth = ZERO; + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // Bandwidth cost: 266 * 1000 SUN = 0.266 TRX + 1 TRX memo = 1.266 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '1.266', + fungible: true, + }, + }, + ]); + }); + + it('adds memo fee combined with account activation fee', async () => { + // Account not activated + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + + const transaction = addMemoToTransaction( + getTransactionExample('native'), + '48656c6c6f', + ); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // 1 TRX activation + 1 TRX memo = 2 TRX + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '2', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + amount: '266', + fungible: true, + }, + }, + ]); + }); + + it('does not add memo fee when data field is absent', async () => { + const transaction = getTransactionExample('native'); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // No memo fee, TRX should be 0 + expect(result[0]?.asset.amount).toBe('0'); + }); + + it('does not add memo fee when data field is empty string', async () => { + const transaction = addMemoToTransaction( + getTransactionExample('native'), + '', + ); + const availableEnergy = ZERO; + const availableBandwidth = BigNumber(1000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // No memo fee, TRX should be 0 + expect(result[0]?.asset.amount).toBe('0'); + }); + + it('adds memo fee on TRC20 transaction with memo', async () => { + mockTronHttpClient.triggerConstantContract.mockResolvedValue({ + energy_used: 130000, + result: { result: true }, + constant_result: [], + transaction: { ret: [{ ret: 'SUCCESS' }] }, + }); + + const transaction = addMemoToTransaction( + getTransactionExample('trc20'), + '48656c6c6f', + ); + const availableEnergy = BigNumber(130000); // Enough energy + const availableBandwidth = BigNumber(2000000); + + const result = await feeCalculatorService.computeFee({ + scope: Network.Mainnet, + transaction, + availableEnergy, + availableBandwidth, + }); + + // 1 TRX memo fee on top of energy (fully covered by staked) + expect(result[0]).toStrictEqual({ + type: FeeType.Base, + asset: { + unit: 'TRX', + type: 'tron:728126428/slip44:195', + amount: '1', + fungible: true, + }, + }); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts new file mode 100644 index 00000000..5db25b71 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts @@ -0,0 +1,798 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { FeeType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; +import type { Transaction } from 'tronweb/lib/esm/types'; + +import type { ComputeFeeResult } from './types'; +import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; +import type { ContractInfo } from '../../clients/tron-http/types'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { Network } from '../../constants'; +import { + ACCOUNT_ACTIVATION_FEE_TRX, + MEMO_FEE_TRX, + Networks, + SUN_IN_TRX, + ZERO, +} from '../../constants'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; + +/** + * Bandwidth calculation constants. + * + * Transaction byte size is calculated based on the protobuf Transaction structure: + * + * ``` + * message Transaction { + * raw raw_data = 1; + * repeated bytes signature = 2; + * repeated Result ret = 5; + * } + * ``` + * + * The formula is: raw_data_bytes + signature_bytes + MAX_RESULT_SIZE_IN_TX + protobuf_overhead + * + * See: https://github.com/tronprotocol/wallet-cli/issues/292 + */ + +/** + * Standard ECDSA signature size (r, s, v) = 65 bytes + */ +const SIGNATURE_SIZE = 65; + +/** + * From java-tron source code: transaction byte size includes MAX_RESULT_SIZE_IN_TX (64 bytes) + * which is added for each contract when VM is supported. + * The ret field is cleared before serialization and this constant is added instead. + */ +const MAX_RESULT_SIZE_IN_TX = 64; + +/** + * Protobuf overhead for LEN-typed tags and length prefixes. + * + * Protobuf represents data as: (tag)(len-prefix if needed)(data) + * - raw_data field: 1 byte tag + 2 bytes len-prefix (data usually > 127 bytes) + * - signature field: 1 byte tag + 1 byte len-prefix (signature is 65 bytes < 127) + * Total: 5 bytes + */ +const PROTOBUF_OVERHEAD = 5; + +const UNSIGNED_TX_OVERHEAD = + SIGNATURE_SIZE + MAX_RESULT_SIZE_IN_TX + PROTOBUF_OVERHEAD; + +/** + * System contracts that only consume bandwidth (zero energy). + * These are native TRON protocol operations that don't execute VM code. + * + * Only CreateSmartContract and TriggerSmartContract consume energy + * because they execute smart contract bytecode. + * + * See: https://tronprotocol.github.io/documentation-en/mechanism-algorithm/system-contracts/ + */ +const ZERO_ENERGY_SYSTEM_CONTRACTS = new Set([ + // Staking + 'FreezeBalanceContract', + 'FreezeBalanceV2Contract', + 'UnfreezeBalanceContract', + 'UnfreezeBalanceV2Contract', + 'WithdrawExpireUnfreezeContract', + 'CancelAllUnfreezeV2Contract', + // Resource delegation + 'DelegateResourceContract', + 'UnDelegateResourceContract', + // Voting + 'VoteWitnessContract', + // Account management + 'AccountCreateContract', + 'AccountUpdateContract', + 'AccountPermissionUpdateContract', + 'SetAccountIdContract', + // Witness/SR operations + 'WitnessCreateContract', + 'WitnessUpdateContract', + 'UpdateBrokerageContract', + // TRC10/Asset operations + 'AssetIssueContract', + 'ParticipateAssetIssueContract', + 'UnfreezeAssetContract', + 'UpdateAssetContract', + // Proposals + 'ProposalCreateContract', + 'ProposalApproveContract', + 'ProposalDeleteContract', + // Exchange + 'ExchangeCreateContract', + 'ExchangeInjectContract', + 'ExchangeWithdrawContract', + 'ExchangeTransactionContract', + // Smart contract management (no VM execution) + 'ClearABIContract', + 'UpdateSettingContract', + 'UpdateEnergyLimitContract', + // Other + 'WithdrawBalanceContract', + 'ShieldedTransferContract', +]); + +export class FeeCalculatorService { + readonly #logger: ILogger; + + readonly #trongridApiClient: TrongridApiClient; + + readonly #tronHttpClient: TronHttpClient; + + constructor({ + logger, + trongridApiClient, + tronHttpClient, + }: { + logger: ILogger; + trongridApiClient: TrongridApiClient; + tronHttpClient: TronHttpClient; + }) { + this.#logger = createPrefixedLogger(logger, '[💸 FeeCalculatorService]'); + this.#trongridApiClient = trongridApiClient; + this.#tronHttpClient = tronHttpClient; + } + + /** + * Calculate the bandwidth needed for a transaction (size in bytes). + * + * Based on java-tron implementation, the transaction byte size is: + * raw_data_bytes + signature_bytes + MAX_RESULT_SIZE_IN_TX + protobuf_overhead + * + * For unsigned transactions, we use the standard ECDSA signature size (65 bytes). + * For signed transactions, we use the actual signature bytes. + * + * See: https://github.com/tronprotocol/wallet-cli/issues/292 + * + * @param transaction - The transaction (signed or unsigned) + * @returns BigNumber - The calculated bandwidth in bytes + */ + #calculateBandwidth(transaction: Transaction): BigNumber { + // eslint-disable-next-line no-restricted-globals + const rawDataBytes = Buffer.from( + transaction.raw_data_hex, + 'hex', + ).byteLength; + + // If transaction is already signed, use actual signature bytes + const signedTx = transaction as Transaction & { signature?: string[] }; + if (signedTx.signature && Array.isArray(signedTx.signature)) { + const signatureBytes = signedTx.signature.reduce( + (sum, signatureHex) => sum + signatureHex.length / 2, + 0, + ); + return BigNumber( + rawDataBytes + + signatureBytes + + MAX_RESULT_SIZE_IN_TX + + PROTOBUF_OVERHEAD, + ); + } + + // Unsigned transaction: assume single standard ECDSA signature (65 bytes) + return BigNumber(rawDataBytes + UNSIGNED_TX_OVERHEAD); + } + + /** + * Calculate the energy requirements for a TRON transaction. + * Depends on the type of contracts in the transaction. + * + * @param scope - The network scope for the transaction + * @param transaction - The transaction (signed or unsigned) + * @param feeLimit - Optional fee limit in SUN to use for fallback calculation + * @returns Promise - The calculated energy consumption + */ + async #calculateEnergy( + scope: Network, + transaction: Transaction, + feeLimit?: number, + ): Promise { + const contracts = transaction.raw_data.contract; + + if (!contracts || contracts.length === 0) { + this.#logger.log( + 'No contracts found in transaction, assuming zero energy usage', + ); + return ZERO; + } + + let totalEnergy = ZERO; + + for (const contract of contracts) { + const contractType = contract.type as string; + this.#logger.log(`Calculating energy for contract type: ${contractType}`); + + let currentContractEnergy: BigNumber; + + if ( + contractType === 'TransferContract' || + contractType === 'TransferAssetContract' + ) { + /** + * Native TRX transfers + TRC10 token transfers + */ + currentContractEnergy = ZERO; + } else if (contractType === 'TriggerSmartContract') { + /** + * Smart contract calls (TRC20, swaps, etc.) + */ + currentContractEnergy = BigNumber( + await this.#estimateTriggerSmartContractEnergy( + scope, + contract, + feeLimit, + ), + ); + } else if (ZERO_ENERGY_SYSTEM_CONTRACTS.has(contractType)) { + /** + * System contracts - don't consume energy + */ + this.#logger.log( + `System contract ${contractType} detected, zero energy consumption`, + ); + currentContractEnergy = ZERO; + } else { + /** + * Unknown contract type, use conservative energy estimate. + */ + this.#logger.warn( + `Unknown contract type: ${contractType}, using conservative estimate`, + ); + currentContractEnergy = BigNumber(130000); + } + + this.#logger.log( + `Contract ${contractType} energy: ${currentContractEnergy.toString()}`, + ); + totalEnergy = totalEnergy.plus(currentContractEnergy); + } + + this.#logger.log(`Total energy for transaction: ${totalEnergy.toString()}`); + + return totalEnergy; + } + + /** + * Check if an account is activated (exists on the network). + * An account is considered not activated if it has never received any assets. + * + * @param scope - The network scope to check + * @param address - The TRON address to check + * @returns Promise - True if the account is activated, false otherwise + */ + async #isAccountActivated(scope: Network, address: string): Promise { + try { + await this.#trongridApiClient.getAccountInfoByAddress(scope, address); + return true; + } catch { + // If the account is not found, it means it's not activated + this.#logger.log(`Account ${address} is not activated on ${scope}`); + return false; + } + } + + /** + * Calculate fallback energy from fee limit. + * Uses fee limit and energy price to derive maximum energy that could be consumed. + * + * @param scope - The network scope for the contract + * @param feeLimit - The fee limit in SUN + * @returns Promise - The calculated max energy from fee limit + */ + async #calculateFallbackEnergyFromFeeLimit( + scope: Network, + feeLimit: number, + ): Promise { + const chainParameters = + await this.#trongridApiClient.getChainParameters(scope); + const energyPrice = + chainParameters.find((param) => param.key === 'getEnergyFee')?.value ?? + 420; // Fallback to 420 SUN per energy unit + + const maxEnergyFromFeeLimit = Math.floor(feeLimit / energyPrice); + + this.#logger.log( + { + feeLimit, + energyPrice, + maxEnergyFromFeeLimit, + }, + `Calculated fallback energy from fee limit`, + ); + + return maxEnergyFromFeeLimit; + } + + /** + * Get fallback energy estimate when simulation fails or is unavailable. + * Uses fee limit to calculate max energy if provided, otherwise returns a conservative default. + * + * @param scope - The network scope for the contract + * @param feeLimit - Optional fee limit in SUN to use for calculation + * @returns Promise - The fallback energy estimate + */ + async #getFallbackEnergy(scope: Network, feeLimit?: number): Promise { + if (feeLimit !== undefined && feeLimit > 0) { + return this.#calculateFallbackEnergyFromFeeLimit(scope, feeLimit); + } + return 130000; // Default conservative estimate + } + + /** + * Calculate how energy is shared between user and contract deployer. + * + * TRON's Energy Sharing Mechanism: + * - consume_user_resource_percent: % of energy the USER pays (0-100). + * 100 = user pays all (default if missing), 0 = deployer pays all. + * - origin_energy_limit: max energy deployer subsidizes per tx. + * 0 = no subsidy (default if missing). + * - deployer's available energy: the deployer's actual energy balance. + * + * @see https://developers.tron.network/docs/energy-consumption-mechanism + * @param totalEnergy - Total energy needed for the transaction + * @param contractInfo - Contract info with energy sharing parameters (null = user pays all) + * @param deployerAvailableEnergy - Deployer's available energy. + * @returns Object containing the energy the user must pay + */ + #calculateEnergySharing( + totalEnergy: number, + contractInfo: ContractInfo | null, + deployerAvailableEnergy: number | null, + ): { userEnergy: number } { + // If no contract info or API failed, user pays everything + if (!contractInfo) { + return { userEnergy: totalEnergy }; + } + + // consume_user_resource_percent: % the USER pays (default 100 = user pays all) + const userPercent = contractInfo.consume_user_resource_percent ?? 100; + // origin_energy_limit: max deployer subsidy (default 0 = no subsidy) + const maxDeployerSubsidy = contractInfo.origin_energy_limit ?? 0; + + // If user pays 100% or deployer has no subsidy budget, user pays all + if (userPercent >= 100 || maxDeployerSubsidy <= 0) { + return { userEnergy: totalEnergy }; + } + + // Calculate theoretical split + const userTheoretical = Math.ceil(totalEnergy * (userPercent / 100)); + const deployerTheoretical = totalEnergy - userTheoretical; + + // Deployer's contribution is capped + let deployerActual = Math.min(deployerTheoretical, maxDeployerSubsidy); + if (deployerAvailableEnergy === null) { + deployerActual = 0; + } else { + deployerActual = Math.min(deployerActual, deployerAvailableEnergy); + } + // User pays the rest + const userActual = totalEnergy - deployerActual; + + this.#logger.log( + { + totalEnergy, + userPercent, + maxDeployerSubsidy, + deployerAvailableEnergy, + userTheoretical, + deployerTheoretical, + deployerActual, + userActual, + }, + 'Energy sharing calculation', + ); + + return { userEnergy: userActual }; + } + + /** + * Fetch the deployer's available energy. + * Returns null if the deployer address is not known or the fetch fails. + * + * @param scope - The network scope + * @param deployerAddress - The deployer's address (hex or base58) + * @returns Promise - Available energy or null on failure + */ + async #getDeployerAvailableEnergy( + scope: Network, + deployerAddress: string | undefined, + ): Promise { + if (!deployerAddress) { + return null; + } + + try { + const resources = await this.#tronHttpClient.getAccountResources( + scope, + deployerAddress, + ); + + // Available energy = EnergyLimit - EnergyUsed + const energyLimit = resources.EnergyLimit ?? 0; + const energyUsed = resources.EnergyUsed ?? 0; + const availableEnergy = Math.max(0, energyLimit - energyUsed); + + this.#logger.log( + { + deployerAddress, + energyLimit, + energyUsed, + availableEnergy, + }, + 'Fetched deployer available energy', + ); + + return availableEnergy; + } catch (error) { + this.#logger.warn( + { error, deployerAddress }, + 'Failed to fetch deployer energy', + ); + return null; + } + } + + /** + * Estimate energy consumption for contract calls of type TriggerSmartContract. + * Uses direct TronGrid API call for accurate energy estimation. + * Accounts for energy sharing mechanism at the contract level. + * Based on TIP-544: https://github.com/tronprotocol/tips/blob/master/tip-544.md + * + * @param scope - The network scope for the contract + * @param contract - The contract object from the transaction + * @param feeLimit - Optional fee limit in SUN to use for fallback calculation + * @returns Promise - The estimated energy the USER will pay (after deployer subsidy) + */ + async #estimateTriggerSmartContractEnergy( + scope: Network, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + contract: any, + feeLimit?: number, + ): Promise { + try { + const { + data, + owner_address: ownerAddress, + contract_address: contractAddress, + call_value: callValue, + token_id: tokenId, + call_token_id: callTokenId, + call_token_value: callTokenValue, + } = contract.parameter.value; + + if (!data) { + this.#logger.warn('No data field found in contract, using fallback'); + throw new Error('No data field found in contract'); + } + + this.#logger.log( + { + contractAddress, + ownerAddress, + callValue, + tokenId, + callTokenId, + callTokenValue, + }, + `Estimating energy`, + ); + + // Fetch contract info and energy estimation in parallel + const [result, contractInfo] = await Promise.all([ + this.#tronHttpClient.triggerConstantContract(scope, { + /** + * These addresses are in hex format. If they weren't we would need to + * pass `visible: true` to the request. + */ + owner_address: ownerAddress, + contract_address: contractAddress, + data, + call_value: callValue, + token_id: tokenId, + call_token_id: callTokenId, + call_token_value: callTokenValue, + }), + // Graceful fallback: if getContract fails, contractInfo will be null + this.#tronHttpClient.getContract(scope, contractAddress).catch(() => { + this.#logger.warn( + 'Failed to fetch contract info for energy sharing, assuming user pays all', + ); + return null; + }), + ]); + + /** + * If the transaction simulation failed, we use the fallback energy estimate. + */ + if (result.transaction.ret[0]?.ret === 'FAILED') { + throw new Error('Simulation yields failed result'); + } + + if ('energy_used' in result) { + const totalEnergy = result.energy_used; + + // Fetch deployer's available energy + const deployerAvailableEnergy = await this.#getDeployerAvailableEnergy( + scope, + contractInfo?.origin_address, + ); + + // Calculate user's portion based on energy sharing + const { userEnergy } = this.#calculateEnergySharing( + totalEnergy, + contractInfo, + deployerAvailableEnergy, + ); + + this.#logger.log( + { + data: data.slice(0, 8), + totalEnergy, + userEnergy, + energyPenalty: result.energy_penalty, + hasEnergySharing: contractInfo !== null, + deployerAvailableEnergy, + }, + `Energy estimate for ${data.slice(0, 8)}: user pays ${userEnergy} of ${totalEnergy} units`, + ); + + return userEnergy; + } + + this.#logger.warn('No energy_used in result, using fallback'); + return this.#getFallbackEnergy(scope, feeLimit); + } catch (error) { + this.#logger.error( + { error }, + 'Failed to estimate smart contract energy, using fallback', + ); + return this.#getFallbackEnergy(scope, feeLimit); + } + } + + /** + * Calculate account activation fees for the transaction. + * This happens when sending native TRX to addresses that haven't been activated yet. + * + * @param options - The options object + * @param options.scope - The network scope to check + * @param options.transaction - The transaction to check for activation fee requirement + * @returns Promise - The total activation fees in TRX + */ + async #accountActivationFees({ + scope, + transaction, + }: { + scope: Network; + transaction: Transaction; + }): Promise { + const contracts = transaction.raw_data.contract; + + if (!contracts || contracts.length === 0) { + return ZERO; + } + + // Collect all recipient addresses from TransferContract operations + const recipientAddresses: string[] = []; + + for (const contract of contracts) { + if ((contract.type as string) === 'TransferContract') { + const { amount, to_address: toAddress } = contract.parameter.value as { + amount: number; + to_address: string; + }; + + if (amount > 0 && toAddress) { + recipientAddresses.push(toAddress); + } + } + } + + if (recipientAddresses.length === 0) { + return ZERO; + } + + // Check all addresses in parallel + const activationResults = await Promise.all( + recipientAddresses.map(async (address) => { + const isActivated = await this.#isAccountActivated(scope, address); + return { address, isActivated }; + }), + ); + + // Count unactivated accounts and calculate total fees + const unactivatedCount = activationResults.filter( + ({ address, isActivated }) => { + if (!isActivated) { + this.#logger.log( + `Account ${address} is not activated, activation fee required`, + ); + return true; + } + return false; + }, + ).length; + + return ACCOUNT_ACTIVATION_FEE_TRX.multipliedBy(unactivatedCount); + } + + /** + * Calculate memo/note fee for the transaction. + * Per Tron protocol, adding a memo/note incurs a flat 1 TRX fee. + * The memo is stored in `raw_data.data` at the transaction level, + * distinct from the contract-level `data` field used for smart contract calls. + * + * @see https://developers.tron.network/docs/tron-protocol-transaction + * @param transaction - The transaction to check for a memo + * @returns BigNumber - The memo fee in TRX (1 TRX if memo present, 0 otherwise) + */ + #memoFee(transaction: Transaction): BigNumber { + const rawData = transaction.raw_data as Record; + const memo = rawData.data as string | undefined; + if (memo && memo.length > 0) { + this.#logger.log('Transaction contains memo, adding 1 TRX memo fee'); + return MEMO_FEE_TRX; + } + return ZERO; + } + + /** + * Calculate complete fee breakdown for a TRON transaction. + * Supports both signed and unsigned transactions. + * Handles both free resource consumption and TRX costs for overages. + * + * @param params - The parameters for the fee calculation + * @param params.scope - The network scope for the transaction + * @param params.transaction - The transaction (signed or unsigned) + * @param params.availableEnergy - Available energy from account + * @param params.availableBandwidth - Available bandwidth from account + * @param params.feeLimit - Optional fee limit in SUN to use for fallback energy calculation + * @returns Promise - Complete fee breakdown + */ + async computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + feeLimit, + }: { + scope: Network; + transaction: Transaction; + availableEnergy: BigNumber; + availableBandwidth: BigNumber; + feeLimit?: number; + }): Promise { + this.#logger.log( + 'Calculating fee for transaction ', + JSON.stringify(transaction), + ); + + const bandwidthNeeded = this.#calculateBandwidth(transaction); + const energyNeeded = await this.#calculateEnergy( + scope, + transaction, + feeLimit, + ); + + /** + * Calculate consumption and overages: + * - Bandwidth: If we don't have enough, we pay for ALL of it in TRX (no partial consumption) + * - Energy: We consume what we have available, and pay TRX only for the overage + */ + const hasEnoughBandwidth = + availableBandwidth.isGreaterThanOrEqualTo(bandwidthNeeded); + const bandwidthConsumed = hasEnoughBandwidth ? bandwidthNeeded : ZERO; + const bandwidthToPayInTRX = hasEnoughBandwidth ? ZERO : bandwidthNeeded; + + const energyConsumed = BigNumber.min(energyNeeded, availableEnergy); + const energyToPayInTRX = BigNumber.max( + energyNeeded.minus(availableEnergy), + ZERO, + ); + + /** + * Calculate the total TRX cost from all sources... + */ + let totalTrxCost = ZERO; + + /** + * First, overages in bandwidth and energy + */ + if ( + bandwidthToPayInTRX.isGreaterThan(0) || + energyToPayInTRX.isGreaterThan(0) + ) { + const chainParameters = + await this.#trongridApiClient.getChainParameters(scope); + + const bandwidthCost = + chainParameters.find((param) => param.key === 'getTransactionFee') + ?.value ?? 1000; // Fallback to 1000 SUN per bandwidth + const energyCost = + chainParameters.find((param) => param.key === 'getEnergyFee')?.value ?? + 100; // Fallback to 100 SUN per energy + + // Calculate TRX cost for bandwidth and energy that needs to be paid + const bandwidthCostTRX = bandwidthToPayInTRX + .multipliedBy(bandwidthCost) + .div(SUN_IN_TRX); // Convert SUN to TRX + const energyCostTRX = energyToPayInTRX + .multipliedBy(energyCost) + .div(SUN_IN_TRX); // Convert SUN to TRX + + totalTrxCost = totalTrxCost.plus(bandwidthCostTRX).plus(energyCostTRX); + } + + /** + * Second, account activation fees + */ + const accountActivationFees = await this.#accountActivationFees({ + scope, + transaction, + }); + + if (accountActivationFees.isGreaterThan(0)) { + totalTrxCost = totalTrxCost.plus(accountActivationFees); + } + + /** + * Third, memo/note fee + */ + const memoFee = this.#memoFee(transaction); + + if (memoFee.isGreaterThan(0)) { + totalTrxCost = totalTrxCost.plus(memoFee); + } + + /** + * Build result array - TRX MUST always be first element, even if 0 + */ + const result: ComputeFeeResult = [ + { + type: FeeType.Base, + asset: { + unit: Networks[scope].nativeToken.symbol, + type: Networks[scope].nativeToken.id, + amount: Number(totalTrxCost.toFixed(6)).toString(), + fungible: true as const, + }, + }, + ]; + + /** + * Add energy consumption fee if we're consuming any energy + */ + if (energyConsumed.isGreaterThan(0)) { + result.push({ + type: FeeType.Base, + asset: { + unit: Networks[scope].energy.symbol, + type: Networks[scope].energy.id, + amount: energyConsumed.toString(), + fungible: true as const, + }, + }); + } + + /** + * Add bandwidth consumption fee if we're consuming any bandwidth + */ + if (bandwidthConsumed.isGreaterThan(0)) { + result.push({ + type: FeeType.Base, + asset: { + unit: Networks[scope].bandwidth.symbol, + type: Networks[scope].bandwidth.id, + amount: bandwidthConsumed.toString(), + fungible: true as const, + }, + }); + } + + return result; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.test.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.test.ts new file mode 100644 index 00000000..d24bb4e2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.test.ts @@ -0,0 +1,1316 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { FeeType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; +import { TronWeb } from 'tronweb'; +import type { Transaction, TransferContract } from 'tronweb/lib/esm/types'; + +import { SendService } from './SendService'; +import { FEE_LIMIT, Network, Networks } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import { SendErrorCodes } from '../../handlers/clientRequest/types'; +import { BackgroundEventMethod } from '../../handlers/cronjob'; +import { mockLogger } from '../../utils/mockLogger'; +import { TransactionExpirationRefresherService } from '../transaction-expiration-refresher/TransactionExpirationRefresherService'; + +describe('SendService', () => { + type MockTransferContract = + Transaction['raw_data']['contract'][number]; + + type MockTransferTransaction = Transaction & { + raw_data: Omit['raw_data'], 'contract'> & { + contract: [MockTransferContract]; + }; + }; + + describe('signAndSendTransaction', () => { + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + const TEST_TO_ADDRESS = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const TEST_OWNER_HEX = '41a614f803b6fd780986a42c78ec9c7f77e6ded13c'; + const TEST_FROM_ADDRESS = TronWeb.address.fromHex(TEST_OWNER_HEX); + const ALT_OWNER_HEX = '41bace09b0c75ff01da2cb86cf05bc0d6d1af21f5d'; + const ALT_ADDRESS = TronWeb.address.fromHex(ALT_OWNER_HEX); + + let sendService: SendService; + let mockAccountsService: any; + let mockAssetsService: any; + let mockTronWebFactory: any; + let mockFeeCalculatorService: any; + let mockSnapClient: any; + let mockTronWeb: any; + let mockTransactionExpirationRefresherService: any; + + const createMockTransaction = (ownerAddress = TEST_OWNER_HEX) => ({ + visible: false, + txID: 'mock-tx-id', + raw_data: { + contract: [ + { + type: 'TransferContract', + parameter: { + value: { + owner_address: ownerAddress, + to_address: TronWeb.address.toHex(TEST_TO_ADDRESS), + amount: 1000000, + }, + }, + }, + ], + }, + raw_data_hex: 'mock-hex', + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockAccountsService = { + findByIdOrThrow: jest.fn().mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: TEST_FROM_ADDRESS, + type: 'tron:eoa', + entropySource: 'test-entropy', + derivationPath: [], + }), + deriveTronKeypair: jest.fn().mockResolvedValue({ + privateKeyHex: 'test-private-key', + address: TEST_FROM_ADDRESS, + }), + }; + + mockAssetsService = { + getAssetsByAccountId: jest.fn(), + }; + + mockTronWeb = { + trx: { + sign: jest.fn().mockResolvedValue({ + ...createMockTransaction(), + signature: ['test-signature'], + }), + sendRawTransaction: jest.fn().mockResolvedValue({ + result: true, + txid: 'broadcast-tx-id', + }), + }, + }; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + }; + + mockFeeCalculatorService = { + computeFee: jest.fn(), + }; + + mockSnapClient = { + trackTransactionSubmitted: jest.fn(), + scheduleBackgroundEvent: jest.fn(), + }; + + mockTransactionExpirationRefresherService = { + ensureFreshMetadata: jest.fn( + async ({ transaction }: { transaction: unknown }) => transaction, + ), + }; + + sendService = new SendService({ + accountsService: mockAccountsService, + assetsService: mockAssetsService, + tronWebFactory: mockTronWebFactory, + feeCalculatorService: mockFeeCalculatorService, + logger: mockLogger, + snapClient: mockSnapClient, + transactionExpirationRefresherService: + mockTransactionExpirationRefresherService as unknown as TransactionExpirationRefresherService, + }); + }); + + it('signs, broadcasts, and tracks a transaction when signer matches owner_address', async () => { + const transaction = createMockTransaction() as any; + + const result = await sendService.signAndSendTransaction({ + scope: Network.Mainnet, + fromAccountId: TEST_ACCOUNT_ID, + transaction, + }); + + expect(mockAccountsService.findByIdOrThrow).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + ); + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: 'test-entropy', + derivationPath: [], + }); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + 'test-private-key', + ); + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(transaction); + expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + signature: ['test-signature'], + }), + ); + expect(mockSnapClient.trackTransactionSubmitted).toHaveBeenCalledWith({ + origin: 'MetaMask', + accountType: 'tron:eoa', + chainIdCaip: Network.Mainnet, + }); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.TrackTransaction, + params: { + txId: 'broadcast-tx-id', + scope: Network.Mainnet, + accountIds: [TEST_ACCOUNT_ID], + attempt: 0, + }, + duration: 'PT1S', + }); + expect(result).toStrictEqual({ + result: true, + txid: 'broadcast-tx-id', + }); + }); + + it('rejects transactions whose owner_address does not match the signer', async () => { + await expect( + sendService.signAndSendTransaction({ + scope: Network.Mainnet, + fromAccountId: TEST_ACCOUNT_ID, + transaction: createMockTransaction(ALT_OWNER_HEX) as any, + }), + ).rejects.toThrow( + `Transaction owner_address (${ALT_ADDRESS}) does not match derived signer address (${TEST_FROM_ADDRESS})`, + ); + + expect(mockTronWeb.trx.sign).not.toHaveBeenCalled(); + expect(mockTronWeb.trx.sendRawTransaction).not.toHaveBeenCalled(); + }); + }); + + describe('validateSend', () => { + let sendService: SendService; + let mockAccountsService: any; + let mockAssetsService: any; + let mockTronWebFactory: any; + let mockFeeCalculatorService: any; + let mockSnapClient: any; + let mockTronWeb: any; + let mockTransactionExpirationRefresherService: { + ensureFreshMetadata: jest.Mock; + }; + + const TEST_ACCOUNT_ID = '550e8400-e29b-41d4-a716-446655440000'; + const TEST_TO_ADDRESS = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const TEST_FROM_ADDRESS = 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT'; + + const scope = Network.Mainnet; + const nativeTokenId = Networks[scope].nativeToken.id; + const bandwidthId = Networks[scope].bandwidth.id; + const energyId = Networks[scope].energy.id; + + const createNativeAsset = (): AssetEntity => + ({ + assetType: nativeTokenId, + keyringAccountId: TEST_ACCOUNT_ID, + network: scope, + symbol: 'TRX', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }) as AssetEntity; + + const createTrc20Asset = (): AssetEntity => + ({ + assetType: `${scope}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, + keyringAccountId: TEST_ACCOUNT_ID, + network: scope, + symbol: 'USDT', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }) as AssetEntity; + + const createMockTransaction = (): MockTransferTransaction => ({ + visible: false, + txID: 'mock-tx-id', + raw_data: { + contract: [ + { + type: 'TransferContract' as MockTransferContract['type'], + parameter: { + type_url: 'type.googleapis.com/protocol.TransferContract', + value: { + owner_address: `41${'a'.repeat(40)}`, + to_address: `41${'b'.repeat(40)}`, + amount: 1000000, + }, + }, + }, + ], + ref_block_bytes: '0000', + ref_block_hash: '0000000000000000', + expiration: 0, + timestamp: 0, + }, + raw_data_hex: 'mock-hex', + }); + + const createBlock = ({ + number, + timestamp, + hashSegment = '1122334455667788', + }: { + number: number; + timestamp: number; + hashSegment?: string; + }) => ({ + blockID: `${'0'.repeat(16)}${hashSegment}${'f'.repeat(32)}`, + block_header: { + raw_data: { + number, + timestamp, + }, + }, + }); + + const getRefBlockBytes = (number: number) => + number.toString(16).slice(-4).padStart(4, '0'); + + beforeEach(() => { + jest.clearAllMocks(); + + mockAccountsService = { + deriveTronKeypair: jest.fn(), + findByIdOrThrow: jest.fn().mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: TEST_FROM_ADDRESS, + entropySource: 'test-entropy', + derivationPath: [], + }), + }; + + mockAssetsService = { + getAssetsByAccountId: jest.fn(), + }; + + mockTronWeb = { + transactionBuilder: { + sendTrx: jest.fn().mockResolvedValue(createMockTransaction()), + sendToken: jest.fn().mockResolvedValue(createMockTransaction()), + triggerSmartContract: jest.fn().mockResolvedValue({ + transaction: createMockTransaction(), + }), + }, + utils: { + transaction: { + txJsonToPb: jest.fn().mockImplementation((tx) => tx), + txPbToRawDataHex: jest.fn().mockReturnValue('1234567890abcdef'), + txPbToTxID: jest.fn().mockReturnValue('mock-tx-id'), + }, + }, + trx: { + getCurrentBlock: jest.fn(), + getBlockByNumber: jest.fn(), + sign: jest.fn(), + sendRawTransaction: jest.fn(), + }, + }; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + }; + + mockFeeCalculatorService = { + computeFee: jest.fn(), + }; + + mockSnapClient = { + scheduleBackgroundEvent: jest.fn(), + trackTransactionSubmitted: jest.fn(), + }; + + mockTransactionExpirationRefresherService = { + ensureFreshMetadata: jest.fn( + async ({ transaction }: { transaction: unknown }) => transaction, + ), + }; + + sendService = new SendService({ + accountsService: mockAccountsService, + assetsService: mockAssetsService, + tronWebFactory: mockTronWebFactory, + feeCalculatorService: mockFeeCalculatorService, + logger: mockLogger, + snapClient: mockSnapClient, + transactionExpirationRefresherService: + mockTransactionExpirationRefresherService as unknown as TransactionExpirationRefresherService, + }); + }); + + describe('native TRX transfers', () => { + it('returns valid when user has enough TRX to cover amount and fees', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(10); // Sending 10 TRX + + // User has 100 TRX, 1000 bandwidth, 0 energy + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, // TRX balance (asset being sent) + { uiAmount: '100', rawAmount: '100000000' }, // TRX balance (native token) + { rawAmount: '1000' }, // Bandwidth + { rawAmount: '0' }, // Energy + ]); + + // Fee is 0 TRX (user has enough bandwidth) + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: bandwidthId, + amount: '266', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + expect(result).toStrictEqual({ valid: true }); + expect(mockAssetsService.getAssetsByAccountId).toHaveBeenCalledWith( + TEST_ACCOUNT_ID, + [nativeTokenId, nativeTokenId, bandwidthId, energyId], + ); + }); + + it('returns InsufficientBalance when user does not have enough TRX to send', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(100); // Trying to send 100 TRX + + // User only has 50 TRX + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '50', rawAmount: '50000000' }, // TRX balance (asset being sent) + { uiAmount: '50', rawAmount: '50000000' }, // TRX balance (native token) + { rawAmount: '1000' }, // Bandwidth + { rawAmount: '0' }, // Energy + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + expect(result).toStrictEqual({ + valid: false, + errorCode: SendErrorCodes.InsufficientBalance, + }); + // Should not call feeCalculatorService since we fail early + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }); + + it('returns InsufficientBalanceToCoverFee when TRX amount + fees exceed balance', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(99); // Sending 99 TRX + + // User has 100 TRX but no bandwidth (fees will be charged in TRX) + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, // TRX balance (asset being sent) + { uiAmount: '100', rawAmount: '100000000' }, // TRX balance (native token) + { rawAmount: '0' }, // No bandwidth + { rawAmount: '0' }, // No energy + ]); + + // Fee is 2 TRX (user has no bandwidth, must pay in TRX) + // Total needed: 99 + 2 = 101 TRX, but user only has 100 + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '2', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + expect(result).toStrictEqual({ + valid: false, + errorCode: SendErrorCodes.InsufficientBalanceToCoverFee, + }); + }); + + it('returns valid when sending exact balance minus fees', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(98); // Sending 98 TRX + + // User has 100 TRX, no bandwidth + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, // TRX balance + { uiAmount: '100', rawAmount: '100000000' }, // TRX balance (native token) + { rawAmount: '0' }, // No bandwidth + { rawAmount: '0' }, // No energy + ]); + + // Fee is 2 TRX + // Total needed: 98 + 2 = 100 TRX, user has exactly 100 + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '2', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + expect(result).toStrictEqual({ valid: true }); + }); + }); + + describe('TRC20 token transfers', () => { + it('returns valid when user has enough tokens and TRX for fees', async () => { + const asset = createTrc20Asset(); + const amount = new BigNumber(50); // Sending 50 USDT + + // User has 100 USDT, 10 TRX for fees, some energy + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, // USDT balance (asset being sent) + { uiAmount: '10', rawAmount: '10000000' }, // TRX balance (native token for fees) + { rawAmount: '500' }, // Bandwidth + { rawAmount: '50000' }, // Energy + ]); + + // Fee is 3 TRX (energy overage) + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '3', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: energyId, + amount: '50000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: bandwidthId, + amount: '345', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + expect(result).toStrictEqual({ valid: true }); + }); + + it('returns InsufficientBalance when user does not have enough tokens', async () => { + const asset = createTrc20Asset(); + const amount = new BigNumber(100); // Trying to send 100 USDT + + // User only has 50 USDT + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '50', rawAmount: '50000000' }, // USDT balance (not enough) + { uiAmount: '100', rawAmount: '100000000' }, // TRX balance (plenty for fees) + { rawAmount: '1000' }, // Bandwidth + { rawAmount: '100000' }, // Energy + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + expect(result).toStrictEqual({ + valid: false, + errorCode: SendErrorCodes.InsufficientBalance, + }); + expect(mockFeeCalculatorService.computeFee).not.toHaveBeenCalled(); + }); + + it('returns InsufficientBalanceToCoverFee when user has tokens but not enough TRX for fees', async () => { + const asset = createTrc20Asset(); + const amount = new BigNumber(50); // Sending 50 USDT + + // User has 100 USDT but only 1 TRX (not enough for fees) + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, // USDT balance (plenty) + { uiAmount: '1', rawAmount: '1000000' }, // TRX balance (not enough for fees) + { rawAmount: '0' }, // No bandwidth + { rawAmount: '0' }, // No energy + ]); + + // Fee is 10 TRX (no energy/bandwidth, everything paid in TRX) + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '10', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + expect(result).toStrictEqual({ + valid: false, + errorCode: SendErrorCodes.InsufficientBalanceToCoverFee, + }); + }); + + it('returns valid when user has tokens and exact TRX for fees', async () => { + const asset = createTrc20Asset(); + const amount = new BigNumber(50); // Sending 50 USDT + + // User has 100 USDT and exactly 5 TRX for fees + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, // USDT balance + { uiAmount: '5', rawAmount: '5000000' }, // TRX balance (exact for fees) + { rawAmount: '0' }, // No bandwidth + { rawAmount: '0' }, // No energy + ]); + + // Fee is exactly 5 TRX + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '5', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + expect(result).toStrictEqual({ valid: true }); + }); + }); + + describe('edge cases', () => { + it('handles missing asset balances (undefined)', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(10); + + // All balances are undefined + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + undefined, // No asset balance + undefined, // No native token balance + undefined, // No bandwidth + undefined, // No energy + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + // Should fail because user has 0 balance + expect(result).toStrictEqual({ + valid: false, + errorCode: SendErrorCodes.InsufficientBalance, + }); + }); + + it('handles zero amount send with fees', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(0); // Sending 0 TRX + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '1', rawAmount: '1000000' }, // 1 TRX balance + { uiAmount: '1', rawAmount: '1000000' }, // 1 TRX native + { rawAmount: '0' }, // No bandwidth + { rawAmount: '0' }, // No energy + ]); + + // Fee is 0.5 TRX + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '0.5', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + // Total needed: 0 + 0.5 = 0.5 TRX, user has 1 TRX + expect(result).toStrictEqual({ valid: true }); + }); + + it('handles fee calculation with no TRX fee (all covered by resources)', async () => { + const asset = createTrc20Asset(); + const amount = new BigNumber(10); + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, // USDT balance + { uiAmount: '0', rawAmount: '0' }, // 0 TRX balance + { rawAmount: '10000' }, // Plenty of bandwidth + { rawAmount: '200000' }, // Plenty of energy + ]); + + // Fee is 0 TRX (user has enough bandwidth and energy) + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '0', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'ENERGY', + type: energyId, + amount: '65000', + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: bandwidthId, + amount: '345', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + // User can send tokens even with 0 TRX because fees are covered by resources + expect(result).toStrictEqual({ valid: true }); + }); + + it('handles account activation fee (1 TRX) for new recipient', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(1); // Sending 1 TRX to new account + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '2', rawAmount: '2000000' }, // 2 TRX balance + { uiAmount: '2', rawAmount: '2000000' }, // 2 TRX native + { rawAmount: '1000' }, // Bandwidth (enough) + { rawAmount: '0' }, // No energy needed for native transfer + ]); + + // Fee includes 1 TRX activation fee + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '1', // 1 TRX activation fee + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: bandwidthId, + amount: '266', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + // Total needed: 1 (amount) + 1 (activation) = 2 TRX, user has exactly 2 + expect(result).toStrictEqual({ valid: true }); + }); + + it('fails when account activation fee causes insufficient balance', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(1.5); // Sending 1.5 TRX to new account + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '2', rawAmount: '2000000' }, // 2 TRX balance + { uiAmount: '2', rawAmount: '2000000' }, // 2 TRX native + { rawAmount: '1000' }, // Bandwidth (enough) + { rawAmount: '0' }, // No energy + ]); + + // Fee includes 1 TRX activation fee + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '1', // 1 TRX activation fee + fungible: true, + }, + }, + { + type: FeeType.Base, + asset: { + unit: 'BANDWIDTH', + type: bandwidthId, + amount: '266', + fungible: true, + }, + }, + ]); + + const result = await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + // Total needed: 1.5 (amount) + 1 (activation) = 2.5 TRX, user only has 2 + expect(result).toStrictEqual({ + valid: false, + errorCode: SendErrorCodes.InsufficientBalanceToCoverFee, + }); + }); + }); + + describe('transaction building', () => { + it('builds transaction with correct toAddress for fee calculation', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(10); + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, + { uiAmount: '100', rawAmount: '100000000' }, + { rawAmount: '1000' }, + { rawAmount: '0' }, + ]); + + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '0', + fungible: true, + }, + }, + ]); + + await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + // Verify transaction was built with the actual toAddress + expect(mockTronWeb.transactionBuilder.sendTrx).toHaveBeenCalledWith( + TEST_TO_ADDRESS, + 10 * 1e6, // Amount in SUN + TEST_FROM_ADDRESS, + ); + + // Verify fee calculator was called with the built transaction + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope, + transaction: expect.objectContaining({ txID: 'mock-tx-id' }), + availableEnergy: expect.any(BigNumber), + availableBandwidth: expect.any(BigNumber), + }); + }); + + it('preserves fractional amount precision for TRX (no floating-point loss)', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber('0.99'); + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, + { uiAmount: '100', rawAmount: '100000000' }, + { rawAmount: '1000' }, + { rawAmount: '0' }, + ]); + + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '0', + fungible: true, + }, + }, + ]); + + await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + // 0.99 TRX = 990000 SUN — not 989999 (which would happen with Number(0.99) * 1e6) + expect(mockTronWeb.transactionBuilder.sendTrx).toHaveBeenCalledWith( + TEST_TO_ADDRESS, + 990000, + TEST_FROM_ADDRESS, + ); + }); + + it('preserves fractional amount precision for TRC20 tokens', async () => { + const asset = createTrc20Asset(); + const amount = new BigNumber('0.99'); + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, + { uiAmount: '100', rawAmount: '100000000' }, + { rawAmount: '1000' }, + { rawAmount: '100000' }, + ]); + + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '0', + fungible: true, + }, + }, + ]); + + await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + }); + + // 0.99 USDT with 6 decimals = 990000 raw — must be exact + expect( + mockTronWeb.transactionBuilder.triggerSmartContract, + ).toHaveBeenCalledWith( + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'transfer(address,uint256)', + {}, + [ + { type: 'address', value: TEST_TO_ADDRESS }, + { type: 'uint256', value: '990000' }, + ], + TEST_FROM_ADDRESS, + ); + }); + + it('propagates feeLimit through validateSend', async () => { + const asset = createNativeAsset(); + const amount = new BigNumber(10); + + mockAssetsService.getAssetsByAccountId.mockResolvedValue([ + { uiAmount: '100', rawAmount: '100000000' }, + { uiAmount: '100', rawAmount: '100000000' }, + { rawAmount: '1000' }, + { rawAmount: '0' }, + ]); + mockFeeCalculatorService.computeFee.mockResolvedValue([ + { + type: FeeType.Base, + asset: { + unit: 'TRX', + type: nativeTokenId, + amount: '0', + fungible: true, + }, + }, + ]); + + await sendService.validateSend({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount, + feeLimit: FEE_LIMIT, + }); + + expect(mockTronWeb.transactionBuilder.sendTrx).toHaveBeenCalledWith( + TEST_TO_ADDRESS, + 10 * 1e6, + TEST_FROM_ADDRESS, + ); + expect(mockFeeCalculatorService.computeFee).toHaveBeenCalledWith({ + scope, + transaction: expect.objectContaining({ + raw_data: expect.objectContaining({ + fee_limit: FEE_LIMIT, + }), + }), + availableEnergy: expect.any(BigNumber), + availableBandwidth: expect.any(BigNumber), + feeLimit: FEE_LIMIT, + }); + }); + + it('applies fee_limit to native transactions and refreshes derived fields', async () => { + const asset = createNativeAsset(); + const result = await sendService.buildTransaction({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount: new BigNumber(10), + feeLimit: FEE_LIMIT, + }); + + expect(mockTronWeb.transactionBuilder.sendTrx).toHaveBeenCalledWith( + TEST_TO_ADDRESS, + 10 * 1e6, + TEST_FROM_ADDRESS, + ); + expect(result.raw_data.fee_limit).toBe(FEE_LIMIT); + expect(mockTronWeb.utils.transaction.txJsonToPb).toHaveBeenCalledWith( + expect.objectContaining({ + raw_data: expect.objectContaining({ + fee_limit: FEE_LIMIT, + }), + }), + ); + expect(result.raw_data_hex).toBe('1234567890abcdef'); + expect(result.txID).toBe('ck-tx-id'); + }); + + it('passes feeLimit through when building TRC20 transactions', async () => { + const asset = createTrc20Asset(); + + await sendService.buildTransaction({ + fromAccountId: TEST_ACCOUNT_ID, + toAddress: TEST_TO_ADDRESS, + asset, + amount: new BigNumber(10), + feeLimit: FEE_LIMIT, + }); + + expect( + mockTronWeb.transactionBuilder.triggerSmartContract, + ).toHaveBeenCalledWith( + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'transfer(address,uint256)', + { feeLimit: FEE_LIMIT }, + expect.any(Array), + TEST_FROM_ADDRESS, + ); + }); + }); + + describe('signAndSendTransaction', () => { + const ownerAddressHex = `41${'a'.repeat(40)}`; + const ownerAddress = 'TRXcKoEvHr6Y38VMcDYGBEYKznvH3XUX4g'; + + beforeEach(() => { + mockAccountsService.findByIdOrThrow.mockResolvedValue({ + id: TEST_ACCOUNT_ID, + address: ownerAddress, + entropySource: 'test-entropy', + derivationPath: [], + type: 'tron:basic', + }); + jest + .spyOn(mockAccountsService, 'deriveTronKeypair') + .mockImplementation() + .mockResolvedValue({ + privateKeyHex: 'test-private-key', + address: ownerAddress, + }); + mockTronWeb.trx.sign.mockImplementation( + async (transaction: unknown) => ({ + transaction, + signature: ['test-signature'], + }), + ); + mockTronWeb.trx.sendRawTransaction.mockResolvedValue({ + result: true, + txid: 'broadcast-tx-id', + }); + }); + + it('refreshes stale transaction metadata before signing and broadcasting', async () => { + sendService = new SendService({ + accountsService: mockAccountsService, + assetsService: mockAssetsService, + tronWebFactory: mockTronWebFactory, + feeCalculatorService: mockFeeCalculatorService, + logger: mockLogger, + snapClient: mockSnapClient, + transactionExpirationRefresherService: + new TransactionExpirationRefresherService({ + tronWebFactory: mockTronWebFactory, + }), + }); + const currentTimestamp = Date.now(); + const currentBlock = createBlock({ + number: 200_000, + timestamp: currentTimestamp, + hashSegment: '0011223344556677', + }); + const transaction = createMockTransaction(); + transaction.raw_data.contract[0].parameter.value.owner_address = + ownerAddressHex; + transaction.raw_data.ref_block_bytes = '0000'; + transaction.raw_data.ref_block_hash = '0000000000000000'; + transaction.raw_data.expiration = currentTimestamp - 1; + transaction.raw_data.timestamp = currentTimestamp - 60_000; + const originalTransaction = structuredClone(transaction); + mockTronWeb.trx.getCurrentBlock.mockResolvedValue(currentBlock); + + await sendService.signAndSendTransaction({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + transaction: transaction as never, + }); + + const signedTransaction = mockTronWeb.trx.sign.mock.calls[0]?.[0]; + expect(signedTransaction).not.toBe(transaction); + expect(signedTransaction.raw_data.ref_block_bytes).toBe( + getRefBlockBytes(200_000), + ); + expect(signedTransaction.raw_data.ref_block_hash).toBe( + '0011223344556677', + ); + expect(signedTransaction.raw_data.expiration).toBe( + currentTimestamp + 60_000, + ); + expect(signedTransaction.raw_data.timestamp).toBe(currentTimestamp); + expect(signedTransaction.raw_data_hex).toBe('1234567890abcdef'); + expect(signedTransaction.txID).toBe('mock-tx-id'); + expect(transaction.raw_data.ref_block_bytes).toBe( + originalTransaction.raw_data.ref_block_bytes, + ); + expect(transaction.raw_data.ref_block_hash).toBe( + originalTransaction.raw_data.ref_block_hash, + ); + expect(transaction.raw_data.expiration).toBe( + originalTransaction.raw_data.expiration, + ); + expect(transaction.raw_data.timestamp).toBe( + originalTransaction.raw_data.timestamp, + ); + expect(transaction.raw_data_hex).toBe(originalTransaction.raw_data_hex); + expect(transaction.txID).toBe(originalTransaction.txID); + expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith({ + transaction: signedTransaction, + signature: ['test-signature'], + }); + }); + + it('signs the transaction returned by the injected expiration refresher', async () => { + const transaction = createMockTransaction(); + const freshTransaction = { + ...createMockTransaction(), + txID: 'fresh-tx-id', + }; + transaction.raw_data.contract[0].parameter.value.owner_address = + ownerAddressHex; + freshTransaction.raw_data.contract[0].parameter.value.owner_address = + ownerAddressHex; + mockTransactionExpirationRefresherService.ensureFreshMetadata.mockResolvedValue( + freshTransaction, + ); + + await sendService.signAndSendTransaction({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + transaction: transaction as never, + }); + + expect( + mockTransactionExpirationRefresherService.ensureFreshMetadata, + ).toHaveBeenCalledWith({ scope, transaction }); + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(freshTransaction); + }); + + it('signs valid transaction metadata without unnecessary modification', async () => { + sendService = new SendService({ + accountsService: mockAccountsService, + assetsService: mockAssetsService, + tronWebFactory: mockTronWebFactory, + feeCalculatorService: mockFeeCalculatorService, + logger: mockLogger, + snapClient: mockSnapClient, + transactionExpirationRefresherService: + new TransactionExpirationRefresherService({ + tronWebFactory: mockTronWebFactory, + }), + }); + const currentTimestamp = Date.now(); + const referencedBlock = createBlock({ + number: 199_990, + timestamp: currentTimestamp - 30_000, + hashSegment: 'abcdef1234567890', + }); + const currentBlock = createBlock({ + number: 200_000, + timestamp: currentTimestamp, + }); + const transaction = createMockTransaction(); + transaction.raw_data.contract[0].parameter.value.owner_address = + ownerAddressHex; + transaction.raw_data.ref_block_bytes = getRefBlockBytes(199_990); + transaction.raw_data.ref_block_hash = 'abcdef1234567890'; + transaction.raw_data.expiration = currentTimestamp + 45_000; + transaction.raw_data.timestamp = currentTimestamp - 30_000; + mockTronWeb.trx.getCurrentBlock.mockResolvedValue(currentBlock); + mockTronWeb.trx.getBlockByNumber.mockResolvedValue(referencedBlock); + + await sendService.signAndSendTransaction({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + transaction: transaction as never, + }); + + expect(transaction.raw_data.ref_block_bytes).toBe( + getRefBlockBytes(199_990), + ); + expect(transaction.raw_data.ref_block_hash).toBe('abcdef1234567890'); + expect(transaction.raw_data.expiration).toBe(currentTimestamp + 45_000); + expect(transaction.raw_data_hex).toBe('mock-hex'); + expect(mockTronWeb.trx.getBlockByNumber).toHaveBeenCalledWith(199_990); + expect(mockTronWeb.utils.transaction.txJsonToPb).not.toHaveBeenCalled(); + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(transaction); + }); + + it('throws when broadcasting a signed transaction fails', async () => { + const currentTimestamp = Date.now(); + const currentBlock = createBlock({ + number: 200_000, + timestamp: currentTimestamp, + hashSegment: 'abcdef1234567890', + }); + const transaction = createMockTransaction(); + transaction.raw_data.contract[0].parameter.value.owner_address = + ownerAddressHex; + transaction.raw_data.ref_block_bytes = getRefBlockBytes(200_000); + transaction.raw_data.ref_block_hash = 'abcdef1234567890'; + transaction.raw_data.expiration = currentTimestamp + 45_000; + transaction.raw_data.timestamp = currentTimestamp; + mockTronWeb.trx.getCurrentBlock.mockResolvedValue(currentBlock); + mockTronWeb.trx.sendRawTransaction.mockResolvedValue({ + result: false, + message: 'expired', + }); + + await expect( + sendService.signAndSendTransaction({ + scope, + fromAccountId: TEST_ACCOUNT_ID, + transaction: transaction as never, + }), + ).rejects.toThrow('Failed to send transaction: expired'); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/send/SendService.ts b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts new file mode 100644 index 00000000..f1d8ebad --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/SendService.ts @@ -0,0 +1,482 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import type { TronWeb } from 'tronweb'; +import type { + BroadcastReturn, + Transaction, + TransferAssetContract, + TransferContract, + TriggerSmartContract, +} from 'tronweb/lib/esm/types'; + +import type { FeeCalculatorService } from './FeeCalculatorService'; +import type { SendValidationResult } from './types'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import type { Network } from '../../constants'; +import { Networks, ZERO } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import { SendErrorCodes } from '../../handlers/clientRequest/types'; +import { BackgroundEventMethod } from '../../handlers/cronjob'; +import { toRawAmount, trxToSun } from '../../utils/conversion'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import { assertTransactionSignerConsistency } from '../../validation/transaction'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { AssetsService } from '../assets/AssetsService'; +import type { TransactionExpirationRefresherService } from '../transaction-expiration-refresher/TransactionExpirationRefresherService'; + +export class SendService { + readonly #accountsService: AccountsService; + + readonly #assetsService: AssetsService; + + readonly #tronWebFactory: TronWebFactory; + + readonly #feeCalculatorService: FeeCalculatorService; + + readonly #logger: ILogger; + + readonly #snapClient: SnapClient; + + readonly #transactionExpirationRefresherService: TransactionExpirationRefresherService; + + constructor({ + accountsService, + assetsService, + tronWebFactory, + feeCalculatorService, + logger, + snapClient, + transactionExpirationRefresherService, + }: { + accountsService: AccountsService; + assetsService: AssetsService; + tronWebFactory: TronWebFactory; + feeCalculatorService: FeeCalculatorService; + logger: ILogger; + snapClient: SnapClient; + transactionExpirationRefresherService: TransactionExpirationRefresherService; + }) { + this.#accountsService = accountsService; + this.#assetsService = assetsService; + this.#tronWebFactory = tronWebFactory; + this.#feeCalculatorService = feeCalculatorService; + this.#logger = createPrefixedLogger(logger, '[💸 SendService]'); + this.#snapClient = snapClient; + this.#transactionExpirationRefresherService = + transactionExpirationRefresherService; + } + + /** + * Validates that the user has enough funds to complete the send operation. + * This includes both the amount being sent and all associated fees + * (bandwidth, energy overages, and account activation if applicable). + * + * @param params - The validation parameters. + * @param params.scope - The network scope. + * @param params.fromAccountId - The account ID to send from. + * @param params.toAddress - The recipient address. + * @param params.asset - The asset being sent. + * @param params.amount - The amount to send (in UI units, e.g., TRX not SUN). + * @param params.feeLimit - The feeLimit to set for the built transaction + * @returns A validation result indicating if the send can proceed. + */ + async validateSend({ + scope, + fromAccountId, + toAddress, + asset, + amount, + feeLimit, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + asset: AssetEntity; + amount: BigNumber; + feeLimit?: number; + }): Promise { + this.#logger.log('Validating send', { + scope, + fromAccountId, + toAddress, + assetType: asset.assetType, + amount: amount.toString(), + }); + + const nativeTokenId = Networks[scope].nativeToken.id; + const isNativeToken = asset.assetType === nativeTokenId; + + /** + * Get the user's current balances for the asset being sent and TRX (for fees). + */ + const [assetBalance, nativeTokenAsset, bandwidthAsset, energyAsset] = + await this.#assetsService.getAssetsByAccountId(fromAccountId, [ + asset.assetType, + nativeTokenId, + Networks[scope].bandwidth.id, + Networks[scope].energy.id, + ]); + + const assetToSendBalance = assetBalance + ? new BigNumber(assetBalance.uiAmount) + : ZERO; + const nativeTokenBalance = nativeTokenAsset + ? new BigNumber(nativeTokenAsset.uiAmount) + : ZERO; + const availableBandwidth = bandwidthAsset + ? new BigNumber(bandwidthAsset.rawAmount) + : ZERO; + const availableEnergy = energyAsset + ? new BigNumber(energyAsset.rawAmount) + : ZERO; + + /** + * First check: Does the user have enough of the asset they want to send? + */ + if (amount.isGreaterThan(assetToSendBalance)) { + this.#logger.log('Insufficient balance for asset being sent', { + amount: amount.toString(), + assetBalance: assetToSendBalance.toString(), + }); + return { + valid: false, + errorCode: SendErrorCodes.InsufficientBalance, + }; + } + + /** + * Build the transaction with the ACTUAL toAddress to properly calculate + * account activation fees if the recipient is not yet activated. + */ + const transaction = await this.buildTransaction({ + fromAccountId, + toAddress, + asset, + amount, + feeLimit, + }); + + /** + * Calculate the fees including: + * - Bandwidth costs (or TRX if insufficient bandwidth) + * - Energy costs (or TRX if insufficient energy) + * - Account activation fee (1 TRX if recipient is not activated) + */ + const fees = await this.#feeCalculatorService.computeFee({ + scope, + transaction, + availableEnergy, + availableBandwidth, + feeLimit, + }); + + /** + * Extract the TRX fee from the computed fees. + * The fee calculation already accounts for bandwidth/energy consumption, + * so we only need to check the TRX overage cost. + */ + const trxFee = new BigNumber( + fees.find((fee) => fee.asset.type === nativeTokenId)?.asset.amount ?? '0', + ); + + /** + * Calculate total TRX needed: + * - If sending TRX: amount + fees + * - If sending a token: just fees (but we still need TRX to pay them) + */ + const totalTrxNeeded = isNativeToken ? amount.plus(trxFee) : trxFee; + + this.#logger.log('Validation calculation', { + isNativeToken, + amount: amount.toString(), + trxFee: trxFee.toString(), + totalTrxNeeded: totalTrxNeeded.toString(), + nativeTokenBalance: nativeTokenBalance.toString(), + }); + + /** + * Second check: Does the user have enough TRX to cover the total cost? + */ + if (totalTrxNeeded.isGreaterThan(nativeTokenBalance)) { + this.#logger.log('Insufficient TRX balance to cover fees', { + totalTrxNeeded: totalTrxNeeded.toString(), + nativeTokenBalance: nativeTokenBalance.toString(), + }); + return { + valid: false, + errorCode: SendErrorCodes.InsufficientBalanceToCoverFee, + }; + } + + return { valid: true }; + } + + async buildTransaction({ + fromAccountId, + toAddress, + asset, + amount, + feeLimit, + }: { + fromAccountId: string; + toAddress: string; + asset: AssetEntity; + amount: BigNumber; + feeLimit?: number; + }): Promise< + | Transaction + | Transaction + | Transaction + > { + const { chainId, assetNamespace, assetReference } = parseCaipAssetType( + asset.assetType, + ); + + try { + switch (assetNamespace) { + case 'slip44': + this.#logger.log('Sending TRX transaction'); + return this.buildSendTrxTransaction({ + scope: chainId as Network, + fromAccountId, + toAddress, + amount, + feeLimit, + }); + + case 'trc10': + this.#logger.log(`Sending TRC10 token: ${assetReference}`); + return this.buildSendTrc10Transaction({ + scope: chainId as Network, + fromAccountId, + toAddress, + amount, + tokenId: assetReference, + decimals: asset.decimals, + feeLimit, + }); + + case 'trc20': + this.#logger.log(`Sending TRC20 token: ${assetReference}`); + return this.buildSendTrc20Transaction({ + scope: chainId as Network, + fromAccountId, + toAddress, + contractAddress: assetReference, + amount, + decimals: asset.decimals, + feeLimit, + }); + + default: + throw new Error(`Unsupported asset namespace: ${assetNamespace}`); + } + } catch (error) { + this.#logger.error({ error }, 'Failed to send asset'); + throw new Error( + `Failed to send asset: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + async buildSendTrxTransaction({ + scope, + fromAccountId, + toAddress, + amount, + feeLimit, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + amount: BigNumber; + feeLimit?: number; + }): Promise> { + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const tronWeb = this.#tronWebFactory.createClient(scope); + + const amountInSun = Number(trxToSun(amount)); + const transaction = await tronWeb.transactionBuilder.sendTrx( + toAddress, + amountInSun, + account.address, + ); + this.#setFeeLimit(tronWeb, transaction, feeLimit); + return transaction; + } + + async buildSendTrc10Transaction({ + scope, + fromAccountId, + toAddress, + amount, + tokenId, + decimals, + feeLimit, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + amount: BigNumber; + tokenId: string; + decimals: number; + feeLimit?: number; + }): Promise> { + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const tronWeb = this.#tronWebFactory.createClient(scope); + + const rawAmount = Number(toRawAmount(amount, decimals)); + + const transaction = await tronWeb.transactionBuilder.sendToken( + toAddress, + rawAmount, + tokenId, + account.address, + ); + this.#setFeeLimit(tronWeb, transaction, feeLimit); + return transaction; + } + + async buildSendTrc20Transaction({ + scope, + fromAccountId, + toAddress, + contractAddress, + amount, + decimals, + feeLimit, + }: { + scope: Network; + fromAccountId: string; + toAddress: string; + contractAddress: string; + amount: BigNumber; + decimals: number; + feeLimit?: number; + }): Promise> { + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + const tronWeb = this.#tronWebFactory.createClient(scope); + + const functionSelector = 'transfer(address,uint256)'; + const decimalsAdjustedAmount = toRawAmount(amount, decimals); + const parameter = [ + { type: 'address', value: toAddress }, + { type: 'uint256', value: decimalsAdjustedAmount }, + ]; + + const contractResult = + await tronWeb.transactionBuilder.triggerSmartContract( + contractAddress, + functionSelector, + feeLimit ? { feeLimit } : {}, + parameter, + account.address, + ); + + return contractResult.transaction; + } + + async signAndSendTransaction({ + scope, + fromAccountId, + transaction, + origin = 'MetaMask', + }: { + scope: Network; + fromAccountId: string; + transaction: + | Transaction + | Transaction + | Transaction; + origin?: string; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise> { + const account = await this.#accountsService.findByIdOrThrow(fromAccountId); + + /** + * Derive the private key for signing + */ + const { privateKeyHex, address: signerAddress } = + await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + assertTransactionSignerConsistency(transaction.raw_data, signerAddress); + + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + const freshTransaction = + await this.#transactionExpirationRefresherService.ensureFreshMetadata({ + scope, + transaction, + }); + + /** + * Sign and send the transaction atomically after user confirmation + */ + const signedTransaction = await tronWeb.trx.sign(freshTransaction); + const result = await tronWeb.trx.sendRawTransaction(signedTransaction); + + if (!result.result) { + throw new Error(`Failed to send transaction: ${result.message}`); + } + + await this.#snapClient.trackTransactionSubmitted({ + origin, + accountType: account.type, + chainIdCaip: scope, + }); + + await this.#snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.TrackTransaction, + params: { + txId: result.txid, + scope, + accountIds: [fromAccountId], + attempt: 0, + }, + duration: 'PT1S', + }); + + return result; + } + + /** + * Applies a fee limit to a TRON transaction and rebuilds its derived fields. + * + * When `feeLimit` is provided, this method: + * - sets `transaction.raw_data.fee_limit` + * - regenerates `raw_data_hex` + * - recalculates `txID` + * + * @param tronWeb - TronWeb instance used to convert the transaction JSON into protobuf form and regenerate transaction metadata. + * @param transaction - The TRX or TRC10 transfer transaction to update. + * @param feeLimit - The fee limit to assign to the transaction in SUN (1 TRX = 1,000,000 SUN). Defaults to FEE_LIMIT (100 TRX). + * If `undefined` or falsy, the transaction is left unchanged. + */ + #setFeeLimit( + tronWeb: TronWeb, + transaction: + | Transaction + | Transaction, + feeLimit: number | undefined, + ): void { + if (feeLimit !== undefined) { + transaction.raw_data.fee_limit = feeLimit; + + const transactionPb = tronWeb.utils.transaction.txJsonToPb(transaction); + + transaction.raw_data_hex = + tronWeb.utils.transaction.txPbToRawDataHex(transactionPb); + transaction.txID = tronWeb.utils.transaction + .txPbToTxID(transactionPb) + .slice(2); + } + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/send/TransactionExpirationRefresherService.test.ts b/merged-packages/tron-wallet-snap/src/services/send/TransactionExpirationRefresherService.test.ts new file mode 100644 index 00000000..d1f7e810 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/TransactionExpirationRefresherService.test.ts @@ -0,0 +1,476 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Network } from '../../constants'; +import { + TransactionExpirationRefresherService, + TRANSACTION_METADATA_REFRESH_ERROR, +} from '../transaction-expiration-refresher/TransactionExpirationRefresherService'; +import type { TransactionWithMetadata } from '../transaction-expiration-refresher/types'; + +const createBlock = ({ + number, + timestamp, + hashSegment = '1122334455667788', +}: { + number: number; + timestamp: number; + hashSegment?: string; +}) => ({ + blockID: `${'0'.repeat(16)}${hashSegment}${'f'.repeat(32)}`, + block_header: { + raw_data: { + number, + timestamp, + }, + }, +}); + +const getRefBlockBytes = (number: number) => + number.toString(16).slice(-4).padStart(4, '0'); + +const createTransaction = ({ + block, + expiration, +}: { + block: ReturnType; + expiration: number; +}) => + ({ + visible: false, + txID: 'original-tx-id', + raw_data: { + contract: [ + { + type: 'TransferContract', + parameter: { + type_url: 'type.googleapis.com/protocol.TransferContract', + value: { + owner_address: `41${'a'.repeat(40)}`, + to_address: `41${'b'.repeat(40)}`, + amount: 1000000, + }, + }, + }, + ], + ref_block_bytes: getRefBlockBytes(block.block_header.raw_data.number), + ref_block_hash: block.blockID.slice(16, 32), + expiration, + timestamp: block.block_header.raw_data.timestamp, + }, + raw_data_hex: 'original-raw-data-hex', + }) as TransactionWithMetadata; + +describe('transaction metadata', () => { + const now = 1_700_000_000_000; + const scope = Network.Mainnet; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(now); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const createTronWeb = ({ + currentBlock = createBlock({ number: 200_000, timestamp: now }), + referencedBlock, + }: { + currentBlock?: ReturnType; + referencedBlock?: ReturnType; + } = {}) => ({ + trx: { + getCurrentBlock: jest.fn().mockResolvedValue(currentBlock), + getBlockByNumber: jest + .fn() + .mockResolvedValue(referencedBlock ?? currentBlock), + }, + utils: { + deserializeTx: { + deserializeTransaction: jest.fn(), + }, + transaction: { + txJsonToPb: jest.fn().mockImplementation((transaction) => transaction), + txPbToRawDataHex: jest.fn().mockReturnValue('refreshed-raw-data-hex'), + txPbToTxID: jest.fn().mockReturnValue('0xrefreshed-tx-id'), + }, + }, + }); + + const createService = (tronWeb: ReturnType) => { + const tronWebFactory = { + createClient: jest.fn().mockReturnValue(tronWeb), + }; + + return { + service: new TransactionExpirationRefresherService({ + tronWebFactory: tronWebFactory as never, + }), + tronWebFactory, + }; + }; + + it('leaves valid transaction metadata unchanged', async () => { + const referencedBlock = createBlock({ + number: 199_990, + timestamp: now - 30_000, + hashSegment: 'abcdef1234567890', + }); + const currentBlock = createBlock({ number: 200_000, timestamp: now }); + const tronWeb = createTronWeb({ currentBlock, referencedBlock }); + const transaction = createTransaction({ + block: referencedBlock, + expiration: now + 45_000, + }); + const { service, tronWebFactory } = createService(tronWeb); + + const result = await service.ensureFreshMetadata({ scope, transaction }); + + expect(result).toBe(transaction); + expect(tronWebFactory.createClient).toHaveBeenCalledWith(scope); + expect(transaction.raw_data.ref_block_bytes).toBe( + getRefBlockBytes(199_990), + ); + expect(transaction.raw_data.ref_block_hash).toBe('abcdef1234567890'); + expect(transaction.raw_data.expiration).toBe(now + 45_000); + expect(transaction.raw_data_hex).toBe('original-raw-data-hex'); + expect(transaction.txID).toBe('original-tx-id'); + expect(tronWeb.trx.getBlockByNumber).toHaveBeenCalledWith(199_990); + expect(tronWeb.utils.transaction.txJsonToPb).not.toHaveBeenCalled(); + }); + + it('leaves valid current-block transaction metadata unchanged', async () => { + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock }); + const transaction = createTransaction({ + block: currentBlock, + expiration: now + 45_000, + }); + const { service, tronWebFactory } = createService(tronWeb); + + const result = await service.ensureFreshMetadata({ scope, transaction }); + + expect(result).toBe(transaction); + expect(tronWebFactory.createClient).toHaveBeenCalledWith(scope); + expect(tronWeb.trx.getBlockByNumber).not.toHaveBeenCalled(); + expect(tronWeb.utils.transaction.txJsonToPb).not.toHaveBeenCalled(); + }); + + it('returns a transaction from serialized raw data with fresh metadata', async () => { + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock }); + const transaction = createTransaction({ + block: currentBlock, + expiration: now + 5_000, + }); + const rawDataHex = '1234567890abcdef'; + tronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue( + transaction.raw_data, + ); + const { service } = createService(tronWeb); + + const result = await service.ensureFreshSerializedTransaction({ + scope, + type: 'TransferContract', + rawDataHex, + }); + + expect( + tronWeb.utils.deserializeTx.deserializeTransaction, + ).toHaveBeenCalledWith('TransferContract', rawDataHex); + expect(result.raw_data.ref_block_bytes).toBe(getRefBlockBytes(200_000)); + expect(result.raw_data.ref_block_hash).toBe('0011223344556677'); + expect(result.raw_data.expiration).toBe(now + 60_000); + expect(result.raw_data_hex).toBe('refreshed-raw-data-hex'); + expect(result.txID).toBe('refreshed-tx-id'); + }); + + it('returns original serialized transaction fields when metadata is already fresh', async () => { + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock }); + const transaction = createTransaction({ + block: currentBlock, + expiration: now + 45_000, + }); + const rawDataHex = '1234567890abcdef'; + tronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue( + transaction.raw_data, + ); + const { service } = createService(tronWeb); + + const result = await service.ensureFreshSerializedTransaction({ + scope, + type: 'TransferContract', + rawDataHex, + }); + + expect(result.raw_data).toBe(transaction.raw_data); + expect(result.raw_data_hex).toBe(rawDataHex); + expect(result.txID).toBe( + 'b09dc9a32de2d32bc21052a2f185044607d11cc58966ba7d7b299fabb7dcbd12', + ); + expect(tronWeb.utils.transaction.txJsonToPb).not.toHaveBeenCalled(); + expect(tronWeb.utils.transaction.txPbToRawDataHex).not.toHaveBeenCalled(); + expect(tronWeb.utils.transaction.txPbToTxID).not.toHaveBeenCalled(); + }); + + it('leaves transaction metadata unchanged when expiration is outside the three-block buffer', async () => { + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock }); + const transaction = createTransaction({ + block: currentBlock, + expiration: now + 10_000, + }); + const { service } = createService(tronWeb); + + const result = await service.ensureFreshMetadata({ scope, transaction }); + + expect(result).toBe(transaction); + expect(tronWeb.utils.transaction.txJsonToPb).not.toHaveBeenCalled(); + }); + + it('refreshes transaction metadata before signing when expiration is close', async () => { + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock }); + const transaction = createTransaction({ + block: currentBlock, + expiration: now + 5_000, + }); + const originalTransaction = structuredClone(transaction); + const { service } = createService(tronWeb); + + const result = await service.ensureFreshMetadata({ scope, transaction }); + + expect(result).not.toBe(transaction); + expect(result.raw_data).not.toBe(transaction.raw_data); + expect(result.raw_data.ref_block_bytes).toBe(getRefBlockBytes(200_000)); + expect(result.raw_data.ref_block_hash).toBe('0011223344556677'); + expect(result.raw_data.expiration).toBe(now + 60_000); + expect(result.raw_data.timestamp).toBe(now); + expect(result.raw_data_hex).toBe('refreshed-raw-data-hex'); + expect(result.txID).toBe('refreshed-tx-id'); + expect(transaction.raw_data.ref_block_bytes).toBe( + originalTransaction.raw_data.ref_block_bytes, + ); + expect(transaction.raw_data.ref_block_hash).toBe( + originalTransaction.raw_data.ref_block_hash, + ); + expect(transaction.raw_data.expiration).toBe( + originalTransaction.raw_data.expiration, + ); + expect(transaction.raw_data.timestamp).toBe( + originalTransaction.raw_data.timestamp, + ); + expect(transaction.raw_data_hex).toBe(originalTransaction.raw_data_hex); + expect(transaction.txID).toBe(originalTransaction.txID); + expect(tronWeb.trx.getBlockByNumber).not.toHaveBeenCalled(); + const serializedTransaction = + tronWeb.utils.transaction.txJsonToPb.mock.calls[0]?.[0]; + expect(serializedTransaction).not.toBe(transaction); + expect(serializedTransaction.raw_data.ref_block_bytes).toBe( + result.raw_data.ref_block_bytes, + ); + expect(serializedTransaction.raw_data.ref_block_hash).toBe( + result.raw_data.ref_block_hash, + ); + }); + + it('returns refreshed raw data when expiration is close', async () => { + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock }); + const transaction = createTransaction({ + block: currentBlock, + expiration: now + 5_000, + }); + const originalRawData = structuredClone(transaction.raw_data); + const { service } = createService(tronWeb); + + const result = await service.ensureFreshRawData({ + scope, + rawData: transaction.raw_data, + }); + + expect(result).not.toBe(transaction.raw_data); + expect(result.ref_block_bytes).toBe(getRefBlockBytes(200_000)); + expect(result.ref_block_hash).toBe('0011223344556677'); + expect(result.expiration).toBe(now + 60_000); + expect(result.timestamp).toBe(now); + expect(transaction.raw_data.ref_block_bytes).toBe( + originalRawData.ref_block_bytes, + ); + expect(transaction.raw_data.ref_block_hash).toBe( + originalRawData.ref_block_hash, + ); + expect(transaction.raw_data.expiration).toBe(originalRawData.expiration); + expect(transaction.raw_data.timestamp).toBe(originalRawData.timestamp); + expect(tronWeb.utils.transaction.txJsonToPb).not.toHaveBeenCalled(); + }); + + it('refreshes transaction metadata when the reference block hash does not match', async () => { + const referencedBlock = createBlock({ + number: 199_990, + timestamp: now - 30_000, + hashSegment: 'abcdef1234567890', + }); + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock, referencedBlock }); + const transaction = createTransaction({ + block: referencedBlock, + expiration: now + 45_000, + }); + transaction.raw_data.ref_block_hash = 'badbadbadbadbad0'; + const originalTransaction = structuredClone(transaction); + const { service } = createService(tronWeb); + + const result = await service.ensureFreshMetadata({ scope, transaction }); + + expect(result).not.toBe(transaction); + expect(result.raw_data.ref_block_bytes).toBe(getRefBlockBytes(200_000)); + expect(result.raw_data.ref_block_hash).toBe('0011223344556677'); + expect(result.raw_data.expiration).toBe(now + 60_000); + expect(result.raw_data_hex).toBe('refreshed-raw-data-hex'); + expect(result.txID).toBe('refreshed-tx-id'); + expect(transaction.raw_data.ref_block_bytes).toBe( + originalTransaction.raw_data.ref_block_bytes, + ); + expect(transaction.raw_data.ref_block_hash).toBe( + originalTransaction.raw_data.ref_block_hash, + ); + expect(transaction.raw_data.expiration).toBe( + originalTransaction.raw_data.expiration, + ); + expect(transaction.raw_data.timestamp).toBe( + originalTransaction.raw_data.timestamp, + ); + expect(transaction.raw_data_hex).toBe(originalTransaction.raw_data_hex); + expect(transaction.txID).toBe(originalTransaction.txID); + }); + + it('refreshes transaction metadata when expiration exceeds the maximum window', async () => { + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock }); + const transaction = createTransaction({ + block: currentBlock, + expiration: now + 24 * 60 * 60 * 1000, + }); + const { service } = createService(tronWeb); + + const result = await service.ensureFreshMetadata({ scope, transaction }); + + expect(result).not.toBe(transaction); + expect(result.raw_data.expiration).toBe(now + 60_000); + expect(result.raw_data.ref_block_hash).toBe('0011223344556677'); + }); + + it('refreshes transaction metadata when reference block bytes are outside the valid window', async () => { + const currentBlock = createBlock({ + number: 10, + timestamp: now, + hashSegment: '0011223344556677', + }); + const tronWeb = createTronWeb({ currentBlock }); + const transaction = createTransaction({ + block: currentBlock, + expiration: now + 45_000, + }); + transaction.raw_data.ref_block_bytes = 'ffff'; + const { service } = createService(tronWeb); + + const result = await service.ensureFreshMetadata({ scope, transaction }); + + expect(result).not.toBe(transaction); + expect(result.raw_data.ref_block_bytes).toBe(getRefBlockBytes(10)); + expect(tronWeb.trx.getBlockByNumber).not.toHaveBeenCalled(); + }); + + it('refreshes transaction metadata when the referenced block cannot be fetched', async () => { + const currentBlock = createBlock({ + number: 200_000, + timestamp: now, + hashSegment: '0011223344556677', + }); + const referencedBlock = createBlock({ + number: 199_990, + timestamp: now - 30_000, + hashSegment: 'abcdef1234567890', + }); + const tronWeb = createTronWeb({ currentBlock, referencedBlock }); + const transaction = createTransaction({ + block: referencedBlock, + expiration: now + 45_000, + }); + tronWeb.trx.getBlockByNumber.mockRejectedValue( + new Error('block unavailable'), + ); + const { service } = createService(tronWeb); + + const result = await service.ensureFreshMetadata({ scope, transaction }); + + expect(result).not.toBe(transaction); + expect(result.raw_data.ref_block_hash).toBe('0011223344556677'); + }); + + it('throws a clear error when refreshed transaction fields cannot be rebuilt', async () => { + const transaction = createTransaction({ + block: createBlock({ number: 199_990, timestamp: now - 30_000 }), + expiration: now - 1, + }); + const tronWeb = createTronWeb(); + tronWeb.utils.transaction.txJsonToPb.mockImplementation(() => { + throw new Error('serialization failed'); + }); + const { service } = createService(tronWeb); + + await expect( + service.ensureFreshMetadata({ scope, transaction }), + ).rejects.toThrow(TRANSACTION_METADATA_REFRESH_ERROR); + }); + + it('throws a clear error when transaction metadata cannot be refreshed safely', async () => { + const transaction = createTransaction({ + block: createBlock({ number: 199_990, timestamp: now - 30_000 }), + expiration: now - 1, + }); + const tronWeb = createTronWeb(); + tronWeb.trx.getCurrentBlock.mockRejectedValue(new Error('node offline')); + const { service } = createService(tronWeb); + + await expect( + service.ensureFreshMetadata({ scope, transaction }), + ).rejects.toThrow(TRANSACTION_METADATA_REFRESH_ERROR); + + expect(tronWeb.trx.getBlockByNumber).not.toHaveBeenCalled(); + expect(tronWeb.utils.transaction.txJsonToPb).not.toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/send/types.ts b/merged-packages/tron-wallet-snap/src/services/send/types.ts new file mode 100644 index 00000000..39b3df87 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/send/types.ts @@ -0,0 +1,37 @@ +import type { FeeType } from '@metamask/keyring-api'; + +import type { SendErrorCodes } from '../../handlers/clientRequest/types'; +import type { + NativeCaipAssetType, + ResourceCaipAssetType, +} from '../assets/types'; + +export type TransactionResult = { + success: boolean; + txId: string; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction: any; +}; + +export type FeeAsset = { + unit: string; + type: NativeCaipAssetType | ResourceCaipAssetType; + amount: string; + fungible: true; + iconUrl?: string; +}; + +export type ComputeFeeResult = { + type: FeeType; + asset: FeeAsset; +}[]; + +export type SendValidationErrorCode = + | SendErrorCodes.InsufficientBalance + | SendErrorCodes.InsufficientBalanceToCoverFee; + +export type SendValidationResult = { + valid: boolean; + errorCode?: SendValidationErrorCode; +}; diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts new file mode 100644 index 00000000..2b2b39be --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.test.ts @@ -0,0 +1,564 @@ +import { BigNumber } from 'bignumber.js'; + +import { StakingService } from './StakingService'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import { + CONSENSYS_SR_NODE_ADDRESS, + KnownCaip19Id, + Network, +} from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { BackgroundEventMethod } from '../../handlers/cronjob'; +import { trxToSun } from '../../utils/conversion'; +import { mockLogger } from '../../utils/mockLogger'; +import type { AccountsService } from '../accounts/AccountsService'; + +describe('StakingService', () => { + let stakingService: StakingService; + let mockAccountsService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockSnapClient: jest.Mocked; + let mockTronWeb: any; + + const mockAccount: TronKeyringAccount = { + id: 'test-account-id', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + const mockKeypair = { + privateKeyHex: + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + // eslint-disable-next-line no-restricted-globals + privateKeyBytes: Buffer.from( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'hex', + ), + // eslint-disable-next-line no-restricted-globals + publicKeyBytes: Buffer.from( + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + 'hex', + ), + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + }; + + const mockTransaction = { + txID: 'mock-transaction-id', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + contract: [ + { + type: 'FreezeBalanceV2Contract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + // eslint-disable-next-line @typescript-eslint/naming-convention + frozen_balance: 1000000, + resource: 'BANDWIDTH', + }, + }, + }, + ], + }, + }; + + const mockSignedTransaction = { + ...mockTransaction, + signature: ['mock-signature'], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockTronWeb = { + transactionBuilder: { + freezeBalanceV2: jest.fn().mockResolvedValue(mockTransaction), + unfreezeBalanceV2: jest.fn().mockResolvedValue(mockTransaction), + vote: jest.fn().mockResolvedValue(mockTransaction), + withdrawExpireUnfreeze: jest.fn().mockResolvedValue(mockTransaction), + withdrawBlockRewards: jest.fn().mockResolvedValue(mockTransaction), + }, + trx: { + sign: jest.fn().mockResolvedValue(mockSignedTransaction), + sendRawTransaction: jest + .fn() + .mockResolvedValue({ result: true, txid: 'mock-tx-id' }), + }, + }; + + mockAccountsService = { + deriveTronKeypair: jest.fn().mockResolvedValue(mockKeypair), + findByIdOrThrow: jest.fn().mockResolvedValue(mockAccount), + } as unknown as jest.Mocked; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + } as unknown as jest.Mocked; + + mockSnapClient = { + scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + stakingService = new StakingService({ + logger: mockLogger, + accountsService: mockAccountsService, + tronWebFactory: mockTronWebFactory, + snapClient: mockSnapClient, + }); + }); + + describe('stake', () => { + it('successfully stakes TRX for BANDWIDTH', async () => { + const amount = BigNumber(1000000); + const assetId = KnownCaip19Id.TrxMainnet; + const purpose = 'BANDWIDTH'; + + await stakingService.stake({ + account: mockAccount, + assetId, + amount, + purpose, + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + derivationPath: mockAccount.derivationPath, + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + mockKeypair.privateKeyHex, + ); + + expect( + mockTronWeb.transactionBuilder.freezeBalanceV2, + ).toHaveBeenCalledWith( + Number(trxToSun(amount)), + purpose, + mockAccount.address, + ); + + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(mockTransaction); + expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith( + mockSignedTransaction, + ); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: mockAccount.id }, + duration: 'PT5S', + }); + }); + + it('successfully stakes TRX for ENERGY', async () => { + const amount = BigNumber(2000000); + const assetId = KnownCaip19Id.TrxNile; + const purpose = 'ENERGY'; + + await stakingService.stake({ + account: mockAccount, + assetId, + amount, + purpose, + }); + + expect( + mockTronWeb.transactionBuilder.freezeBalanceV2, + ).toHaveBeenCalledWith( + Number(trxToSun(amount)), + purpose, + mockAccount.address, + ); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Nile, + mockKeypair.privateKeyHex, + ); + }); + + it('correctly parses chainId from different network asset IDs', async () => { + const testCases = [ + { assetId: KnownCaip19Id.TrxMainnet, expectedNetwork: Network.Mainnet }, + { assetId: KnownCaip19Id.TrxNile, expectedNetwork: Network.Nile }, + { assetId: KnownCaip19Id.TrxShasta, expectedNetwork: Network.Shasta }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.stake({ + account: mockAccount, + assetId: testCase.assetId as any, + amount: BigNumber(1000000), + purpose: 'BANDWIDTH', + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + testCase.expectedNetwork, + mockKeypair.privateKeyHex, + ); + } + }); + + it('handles different amount values correctly', async () => { + const testCases = [ + { amount: BigNumber(1) }, + { amount: BigNumber(1000000) }, + { amount: BigNumber('1000000000000') }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.stake({ + account: mockAccount, + assetId: KnownCaip19Id.TrxMainnet, + amount: testCase.amount, + purpose: 'BANDWIDTH', + }); + + expect( + mockTronWeb.transactionBuilder.freezeBalanceV2, + ).toHaveBeenCalledWith( + Number(trxToSun(testCase.amount)), + 'BANDWIDTH', + mockAccount.address, + ); + } + }); + + it('correctly derives keypair for staking', async () => { + const customAccount = { + ...mockAccount, + entropySource: 'custom-entropy', + derivationPath: "m/44'/195'/1'/0/0" as const, + }; + + await stakingService.stake({ + account: customAccount, + assetId: KnownCaip19Id.TrxMainnet as any, + amount: BigNumber(1000000), + purpose: 'BANDWIDTH', + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: 'custom-entropy', + derivationPath: "m/44'/195'/1'/0/0", + }); + }); + + it('uses Consensys SR node address by default when srNodeAddress is not provided', async () => { + const amount = BigNumber(100); + const assetId = KnownCaip19Id.TrxMainnet; + const purpose = 'BANDWIDTH'; + + await stakingService.stake({ + account: mockAccount, + assetId, + amount, + purpose, + }); + + expect(mockTronWeb.transactionBuilder.vote).toHaveBeenCalledWith( + { [CONSENSYS_SR_NODE_ADDRESS]: 100 }, + mockAccount.address, + ); + }); + + it('uses custom SR node address when provided', async () => { + const amount = BigNumber(100); + const assetId = KnownCaip19Id.TrxMainnet; + const purpose = 'BANDWIDTH'; + const customSrNodeAddress = 'TSR1234567890abcdefghijklmnopqrstuv'; + + await stakingService.stake({ + account: mockAccount, + assetId, + amount, + purpose, + srNodeAddress: customSrNodeAddress, + }); + + expect(mockTronWeb.transactionBuilder.vote).toHaveBeenCalledWith( + { [customSrNodeAddress]: 100 }, + mockAccount.address, + ); + }); + + it('overrides Consensys SR node with custom address when provided', async () => { + const amount = BigNumber(500); + const assetId = KnownCaip19Id.TrxNile; + const purpose = 'ENERGY'; + const customSrNodeAddress = 'TCustomSRNode12345678901234567890'; + + await stakingService.stake({ + account: mockAccount, + assetId, + amount, + purpose, + srNodeAddress: customSrNodeAddress, + }); + + expect(mockTronWeb.transactionBuilder.vote).toHaveBeenCalledWith( + { [customSrNodeAddress]: 500 }, + mockAccount.address, + ); + + // Verify Consensys SR node was NOT used + expect(mockTronWeb.transactionBuilder.vote).not.toHaveBeenCalledWith( + expect.objectContaining({ + [CONSENSYS_SR_NODE_ADDRESS]: expect.any(Number), + }), + expect.anything(), + ); + }); + }); + + describe('unstake', () => { + it('successfully unstakes TRX staked for BANDWIDTH on Mainnet', async () => { + const amount = BigNumber(1000000); + const assetId = KnownCaip19Id.TrxStakedForBandwidthMainnet; + + await stakingService.unstake({ + account: mockAccount, + assetId, + amount, + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + derivationPath: mockAccount.derivationPath, + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + mockKeypair.privateKeyHex, + ); + + expect( + mockTronWeb.transactionBuilder.unfreezeBalanceV2, + ).toHaveBeenCalledWith( + Number(trxToSun(amount)), + 'BANDWIDTH', + mockAccount.address, + ); + + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(mockTransaction); + expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith( + mockSignedTransaction, + ); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: mockAccount.id }, + duration: 'PT5S', + }); + }); + + it('successfully unstakes TRX staked for BANDWIDTH on all networks', async () => { + const testCases = [ + { + assetId: KnownCaip19Id.TrxStakedForBandwidthMainnet, + expectedNetwork: Network.Mainnet, + }, + { + assetId: KnownCaip19Id.TrxStakedForBandwidthNile, + expectedNetwork: Network.Nile, + }, + { + assetId: KnownCaip19Id.TrxStakedForBandwidthShasta, + expectedNetwork: Network.Shasta, + }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.unstake({ + account: mockAccount, + assetId: testCase.assetId as any, + amount: BigNumber(1000000), + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + testCase.expectedNetwork, + mockKeypair.privateKeyHex, + ); + + expect( + mockTronWeb.transactionBuilder.unfreezeBalanceV2, + ).toHaveBeenCalledWith( + Number(trxToSun(1000000)), + 'BANDWIDTH', + mockAccount.address, + ); + } + }); + + it('successfully unstakes TRX staked for ENERGY on all networks', async () => { + const testCases = [ + { + assetId: KnownCaip19Id.TrxStakedForEnergyMainnet, + expectedNetwork: Network.Mainnet, + }, + { + assetId: KnownCaip19Id.TrxStakedForEnergyNile, + expectedNetwork: Network.Nile, + }, + { + assetId: KnownCaip19Id.TrxStakedForEnergyShasta, + expectedNetwork: Network.Shasta, + }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.unstake({ + account: mockAccount, + assetId: testCase.assetId as any, + amount: BigNumber(2000000), + }); + + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + testCase.expectedNetwork, + mockKeypair.privateKeyHex, + ); + + expect( + mockTronWeb.transactionBuilder.unfreezeBalanceV2, + ).toHaveBeenCalledWith( + Number(trxToSun(2000000)), + 'ENERGY', + mockAccount.address, + ); + } + }); + + it('throws error for invalid asset ID', async () => { + const invalidAssetId = 'tron:728126428/slip44:invalid' as any; + const amount = BigNumber(1000000); + + await expect( + stakingService.unstake({ + account: mockAccount, + assetId: invalidAssetId, + amount, + }), + ).rejects.toThrow('Invalid asset ID'); + }); + + it('correctly derives keypair for unstaking', async () => { + const customAccount = { + ...mockAccount, + entropySource: 'custom-entropy', + derivationPath: "m/44'/195'/2'/0/0" as const, + }; + + await stakingService.unstake({ + account: customAccount, + assetId: KnownCaip19Id.TrxStakedForBandwidthMainnet as any, + amount: BigNumber(1000000), + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: 'custom-entropy', + derivationPath: "m/44'/195'/2'/0/0", + }); + }); + + it('handles different amount values correctly', async () => { + const testCases = [ + { amount: BigNumber(1) }, + { amount: BigNumber(1000000) }, + { amount: BigNumber('1000000000000') }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + await stakingService.unstake({ + account: mockAccount, + assetId: KnownCaip19Id.TrxStakedForBandwidthMainnet, + amount: testCase.amount, + }); + + expect( + mockTronWeb.transactionBuilder.unfreezeBalanceV2, + ).toHaveBeenCalledWith( + Number(trxToSun(testCase.amount)), + 'BANDWIDTH', + mockAccount.address, + ); + } + }); + }); + + describe('claimUnstakedTrx', () => { + it('builds, signs, and broadcasts a withdrawExpireUnfreeze transaction', async () => { + await stakingService.claimUnstakedTrx({ + account: mockAccount, + scope: Network.Mainnet, + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + derivationPath: mockAccount.derivationPath, + }); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + mockKeypair.privateKeyHex, + ); + expect( + mockTronWeb.transactionBuilder.withdrawExpireUnfreeze, + ).toHaveBeenCalledWith(mockAccount.address); + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(mockTransaction); + expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith( + mockSignedTransaction, + ); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: mockAccount.id }, + duration: 'PT5S', + }); + }); + }); + + describe('claimTrxStakingRewards', () => { + it('builds, signs, and broadcasts a withdrawBlockRewards transaction', async () => { + await stakingService.claimTrxStakingRewards({ + account: mockAccount, + scope: Network.Mainnet, + }); + + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + derivationPath: mockAccount.derivationPath, + }); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + mockKeypair.privateKeyHex, + ); + expect( + mockTronWeb.transactionBuilder.withdrawBlockRewards, + ).toHaveBeenCalledWith(mockAccount.address); + expect(mockTronWeb.trx.sign).toHaveBeenCalledWith(mockTransaction); + expect(mockTronWeb.trx.sendRawTransaction).toHaveBeenCalledWith( + mockSignedTransaction, + ); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: mockAccount.id }, + duration: 'PT5S', + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts new file mode 100644 index 00000000..fecc8c1c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/staking/StakingService.ts @@ -0,0 +1,198 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import type { Resource } from 'tronweb/lib/esm/types'; + +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import type { Network } from '../../constants'; +import { CONSENSYS_SR_NODE_ADDRESS, KnownCaip19Id } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { trxToSun } from '../../utils/conversion'; +import { executeOnChainActions } from '../../utils/executeOnChainActions'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { NativeCaipAssetType, StakedCaipAssetType } from '../assets/types'; + +export class StakingService { + readonly #logger: ILogger; + + readonly #accountsService: AccountsService; + + readonly #tronWebFactory: TronWebFactory; + + readonly #snapClient: SnapClient; + + constructor({ + logger, + accountsService, + tronWebFactory, + snapClient, + }: { + logger: ILogger; + accountsService: AccountsService; + tronWebFactory: TronWebFactory; + snapClient: SnapClient; + }) { + this.#logger = createPrefixedLogger(logger, '[💸 StakingService]'); + this.#accountsService = accountsService; + this.#tronWebFactory = tronWebFactory; + this.#snapClient = snapClient; + } + + async stake({ + account, + assetId, + amount, + purpose, + srNodeAddress, + }: { + account: TronKeyringAccount; + assetId: NativeCaipAssetType; + amount: BigNumber; + purpose: 'BANDWIDTH' | 'ENERGY'; + /** + * Optional SR node address to allocate votes to. + * If not provided, defaults to the Consensys SR node. + */ + srNodeAddress?: string; + }): Promise { + const { chainId } = parseCaipAssetType(assetId); + const amountInSun = Number(trxToSun(amount)); + const availableVotes = amount.integerValue(BigNumber.ROUND_DOWN).toNumber(); + const voteRecipient = srNodeAddress ?? CONSENSYS_SR_NODE_ADDRESS; + + this.#logger.info( + `Staking ${amount.toString()} ${assetId} for ${purpose} for ${account.address} on ${chainId}...`, + ); + + await executeOnChainActions({ + accountsService: this.#accountsService, + tronWebFactory: this.#tronWebFactory, + snapClient: this.#snapClient, + account, + scope: chainId as Network, + buildTransactions: async (tronWeb) => [ + await tronWeb.transactionBuilder.freezeBalanceV2( + amountInSun, + purpose, + account.address, + ), + await tronWeb.transactionBuilder.vote( + { [voteRecipient]: availableVotes }, + account.address, + ), + ], + }); + } + + async unstake({ + account, + assetId, + amount, + }: { + account: TronKeyringAccount; + assetId: StakedCaipAssetType; + amount: BigNumber; + }): Promise { + const { chainId } = parseCaipAssetType(assetId); + + /** + * Check which resource we are unstaking. + */ + let purpose: Resource | undefined; + + if ( + [ + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForBandwidthNile, + KnownCaip19Id.TrxStakedForBandwidthShasta, + ].includes(assetId as KnownCaip19Id) + ) { + purpose = 'BANDWIDTH'; + } + + if ( + [ + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxStakedForEnergyNile, + KnownCaip19Id.TrxStakedForEnergyShasta, + ].includes(assetId as KnownCaip19Id) + ) { + purpose = 'ENERGY'; + } + + if (!purpose) { + throw new Error('Invalid asset ID'); + } + + const amountInSun = Number(trxToSun(amount)); + + this.#logger.info( + `Unstaking ${amount.toString()} ${assetId} for ${account.address} on ${chainId}...`, + ); + + await executeOnChainActions({ + accountsService: this.#accountsService, + tronWebFactory: this.#tronWebFactory, + snapClient: this.#snapClient, + account, + scope: chainId as Network, + buildTransactions: async (tronWeb) => [ + await tronWeb.transactionBuilder.unfreezeBalanceV2( + amountInSun, + purpose, + account.address, + ), + ], + }); + } + + async claimUnstakedTrx({ + account, + scope, + }: { + account: TronKeyringAccount; + scope: Network; + }): Promise { + this.#logger.info( + `Claiming unstaked TRX for ${account.address} on ${scope}...`, + ); + + await executeOnChainActions({ + accountsService: this.#accountsService, + tronWebFactory: this.#tronWebFactory, + snapClient: this.#snapClient, + account, + scope, + buildTransactions: async (tronWeb) => [ + await tronWeb.transactionBuilder.withdrawExpireUnfreeze( + account.address, + ), + ], + }); + } + + async claimTrxStakingRewards({ + account, + scope, + }: { + account: TronKeyringAccount; + scope: Network; + }): Promise { + this.#logger.info( + `Claiming staking rewards for ${account.address} on ${scope}...`, + ); + + await executeOnChainActions({ + accountsService: this.#accountsService, + tronWebFactory: this.#tronWebFactory, + snapClient: this.#snapClient, + account, + scope, + buildTransactions: async (tronWeb) => [ + await tronWeb.transactionBuilder.withdrawBlockRewards(account.address), + ], + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/state/IStateManager.ts b/merged-packages/tron-wallet-snap/src/services/state/IStateManager.ts new file mode 100644 index 00000000..a60b03f6 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/state/IStateManager.ts @@ -0,0 +1,105 @@ +import type { Serializable } from '../../utils/serialization/types'; + +export type IStateManager> = { + /** + * Gets the whole state object. + * + * ⚠️ WARNING: Use with caution because it transfers the whole state, which might contain a lot of data. + * If you need to retrieve only a specific part of the state, use IStateManager.getKey instead. + * + * @example + * ```typescript + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * + * const value = await stateManager.get(); + * // value is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * ``` + */ + get(): Promise; + /** + * Gets the value of passed key in the state object. + * The key is the json path to the value to get. + * + * @example + * ```typescript + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } + * + * const value = await stateManager.getKey('users.1.name'); + * // value is 'Bob' + * + * @returns The value of the key, or undefined if the key does not exist. + */ + getKey( + key: string, + ): Promise; + /** + * Sets the value of passed key in the state object. + * The key is a json path to the value to set. + * + * @example + * ```typescript + * const state = await stateManager.get(); + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } + * + * await stateManager.set('users.1.name', 'John'); + * // state is now { users: [ { name: 'Alice', age: 20 }, { name: 'John', age: 25 } ] } + * ``` + * @param key - The key to set, which is a json path to the location. + * @param value - The value to set. + */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setKey(key: string, value: any): Promise; + /** + * Atomically reads the current value at `key`, applies `updater`, and writes the result back. + * + * Implementations must ensure that no concurrent state write can interleave between the + * read and the write. This makes the method safe for updates where the next value depends + * on the current value, such as merging objects. + * + * Prefer this over a manual `getKey` + `setKey` sequence whenever the new value depends on + * the current one. + * + * @param key - The json-path key to update. + * @param updater - Receives the current value (or `undefined` when the key is absent) and + * returns the new value to store. + */ + setKeyWith( + key: string, + updater: (currentValue: TValue | undefined) => TValue, + ): Promise; + /** + * Updates the whole state object. + * + * Typically used for bulk `set`s or `delete`s, because: + * - Atomicity: Using a single `state.update` ensures that all changes are applied atomically. If any part of the operation fails, none of the changes will be applied. This prevents partial updates that could leave the underlying data store in an inconsistent state. + * - Performance: Making multiple individual `state.set` or `state.delete` calls would require multiple round trips to the state storage system, causing potential overheads. + * - State Consistency: Maintains better state consistency by reading the state once, making all modifications in memory and writing the complete updated state back. + * + * ⚠️ WARNING: Use with caution because: + * - it will override the whole state. + * - it transfers the whole state back and forth the data store, which might consume a lot of bandwidth. + * + * For single updates, use instead `setKey` or `deleteKey`. + * + * @param updaterFunction - The function that updates the state. + * @returns The updated state. + */ + update( + updaterFunction: (state: TStateValue) => TStateValue, + ): Promise; + /** + * Deletes the value of passed key in the state object. + * The key is a json path to the value to delete. + * + * @example + * ```typescript + * const state = await stateManager.get(); + * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } + * + * await stateManager.deleteKey('users.1'); + * // state is now { users: [ { name: 'Alice', age: 20 } ] } + * ``` + */ + deleteKey(key: string): Promise; +}; diff --git a/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts b/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts new file mode 100644 index 00000000..03bf2cca --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/state/InMemoryState.ts @@ -0,0 +1,56 @@ +import { get, set, unset } from 'lodash'; + +import type { IStateManager } from './IStateManager'; +import type { Serializable } from '../../utils/serialization/types'; + +/** + * A simple implementation of the `IStateManager` interface that relies on an in memory state that can be used for testing purposes. + */ +export class InMemoryState< + TStateValue extends Record, +> implements IStateManager { + #state: TStateValue; + + constructor(initialState: TStateValue) { + this.#state = initialState; + } + + async get(): Promise { + return this.#state; + } + + async getKey( + key: string, + ): Promise { + const value = get(this.#state, key); + + return value as TResponse | undefined; + } + + async setKey(key: string, value: Serializable): Promise { + set(this.#state, key, value); // Use lodash to set the value using a json path + } + + async setKeyWith( + key: string, + updater: (currentValue: TValue | undefined) => TValue, + ): Promise { + const oldValue = get(this.#state, key) as TValue | undefined; + const newValue = updater(oldValue); + + set(this.#state, key, newValue); + } + + async update( + callback: (state: TStateValue) => TStateValue, + ): Promise { + this.#state = callback(this.#state); + + return this.#state; + } + + async deleteKey(key: string): Promise { + // Using lodash's unset to leverage the json path capabilities + unset(this.#state, key); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.test.ts b/merged-packages/tron-wallet-snap/src/services/state/State.test.ts new file mode 100644 index 00000000..8f92ef07 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/state/State.test.ts @@ -0,0 +1,498 @@ +/* eslint-disable jest/prefer-strict-equal */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { BigNumber } from 'bignumber.js'; + +import { State } from './State'; + +const snap = { + request: jest.fn(), +}; + +(globalThis as any).snap = snap; + +const flushPromises = async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +}; + +type DelayedStateRequest = + | { + method: 'snap_getState'; + params: { key: string }; + } + | { + method: 'snap_setState'; + params: { key: string; value: Record }; + }; + +/** + * Mocks state reads so tests can hold pending `snap_getState` calls. + * + * @param storedValues - Mutable backing store returned from mocked state reads. + * @param getResolvers - Resolver queue for delayed mocked state reads. + */ +function mockDelayedStateRequests( + storedValues: Record>, + getResolvers: (() => void)[], +): void { + snap.request.mockImplementation(async (request: DelayedStateRequest) => { + if (request.method === 'snap_getState') { + await new Promise((resolve) => { + getResolvers.push(resolve); + }); + return storedValues[request.params.key] ?? null; + } + + if (request.method === 'snap_setState') { + storedValues[request.params.key] = request.params.value; + } + + return undefined; + }); +} + +type User = { + name: string; + age: BigNumber | bigint | number | undefined | null; +}; + +type MockStateValue = { + users: User[]; +}; + +const DEFAULT_STATE: MockStateValue = { + users: [ + { + name: 'John', + age: 30, + }, + { + name: 'Jane', + age: 25, + }, + ], +}; + +describe('State', () => { + let state: State; + + beforeEach(() => { + state = new State({ + encrypted: false, + defaultState: DEFAULT_STATE, + }); + + jest.clearAllMocks(); + }); + + afterEach(() => { + snap.request.mockReset(); + }); + + describe('get', () => { + it('gets the state', async () => { + const mockUnderlyingState = DEFAULT_STATE; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { encrypted: false }, + }); + expect(stateValue).toStrictEqual(mockUnderlyingState); + }); + + it('gets the default state if the snap state is empty', async () => { + const mockUnderlyingState = {}; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual(DEFAULT_STATE); + }); + + describe('when getting serialized non-JSON values', () => { + it('deserializes undefined values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'undefined', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toEqual({ + users: [ + { + name: 'John', + age: undefined, + }, + ], + }); + }); + + it('deserializes BigNumber values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'BigNumber', + value: '30', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual({ + users: [ + { + name: 'John', + age: new BigNumber(30), + }, + ], + }); + }); + + it('deserializes bigint values', async () => { + const mockUnderlyingState = { + users: [ + { + name: 'John', + age: { + __type: 'bigint', + value: '30', + }, + }, + ], + }; + snap.request.mockResolvedValue(mockUnderlyingState); + + const stateValue = await state.get(); + + expect(stateValue).toStrictEqual({ + users: [ + { + name: 'John', + age: BigInt(30), + }, + ], + }); + }); + }); + }); + + describe('getKey', () => { + it('calls the snap_getState method with the correct parameters', async () => { + const mockUnderlyingState = DEFAULT_STATE; + snap.request.mockResolvedValue(mockUnderlyingState); + + await state.getKey('users.1.name'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { key: 'users.1.name', encrypted: false }, + }); + }); + + it('returns undefined if the key does not exist', async () => { + snap.request.mockResolvedValue(null); + + const value = await state.getKey('users.1.name'); + + expect(value).toBeUndefined(); + }); + }); + + describe('setKey', () => { + it('sets the value of a key', async () => { + await state.setKey('users.1.name', 'Bob'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_setState', + params: { + key: 'users.1.name', + value: 'Bob', + encrypted: false, + }, + }); + }); + }); + + describe('setKeyWith', () => { + it('reads the current value, applies the updater, and writes the result', async () => { + snap.request.mockResolvedValueOnce({ alice: 10 }); // getState (read) + + await state.setKeyWith>('scores', (current) => ({ + ...current, + bob: 20, + })); + + expect(snap.request).toHaveBeenNthCalledWith(1, { + method: 'snap_getState', + params: { key: 'scores', encrypted: false }, + }); + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_setState', + params: { + key: 'scores', + value: { alice: 10, bob: 20 }, + encrypted: false, + }, + }); + }); + + it('passes undefined to the updater when the key does not exist', async () => { + snap.request.mockResolvedValueOnce(null); // getState returns null → key absent + + const updater = jest.fn().mockReturnValue({ bob: 20 }); + + await state.setKeyWith('scores', updater); + + expect(updater).toHaveBeenCalledWith(undefined); + }); + + it('serializes concurrent read-modify-write updates', async () => { + const storedValues: Record> = { + scores: { alice: 10 }, + }; + const getResolvers: (() => void)[] = []; + + mockDelayedStateRequests(storedValues, getResolvers); + + const firstUpdate = state.setKeyWith>( + 'scores', + (current) => ({ + ...current, + bob: 20, + }), + ); + const secondUpdate = state.setKeyWith>( + 'scores', + (current) => ({ + ...current, + carol: 30, + }), + ); + + await flushPromises(); + + expect(getResolvers).toHaveLength(1); + + getResolvers[0]?.(); + + await flushPromises(); + + expect(getResolvers).toHaveLength(2); + + getResolvers[1]?.(); + + await Promise.all([firstUpdate, secondUpdate]); + + expect(storedValues.scores).toStrictEqual({ + alice: 10, + bob: 20, + carol: 30, + }); + }); + }); + + describe('update', () => { + it('updates the state', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: 50, + }, + ], + })); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_getState', + params: { encrypted: false }, + }); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: 50, + }, + ], + }, + }, + }); + }); + + describe('when updating serialized non-JSON values', () => { + it('serializes undefined values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: undefined, + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'undefined', + }, + }, + ], + }, + }, + }); + }); + + it('serializes BigNumber values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: new BigNumber(50), + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'BigNumber', + value: '50', + }, + }, + ], + }, + }, + }); + }); + + it('serializes bigint values', async () => { + await state.update((currentState) => ({ + users: [ + ...currentState.users, + { + name: 'Bob', + age: BigInt(50), + }, + ], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [ + ...DEFAULT_STATE.users, + { + name: 'Bob', + age: { + __type: 'bigint', + value: '50', + }, + }, + ], + }, + }, + }); + }); + + it('serializes null values', async () => { + await state.update((currentState) => ({ + users: [...currentState.users, { name: 'Bob', age: null }], + })); + + expect(snap.request).toHaveBeenNthCalledWith(2, { + method: 'snap_manageState', + params: { + operation: 'update', + encrypted: false, + newState: { + users: [...DEFAULT_STATE.users, { name: 'Bob', age: null }], + }, + }, + }); + }); + }); + }); + + describe('deleteKey', () => { + it('deletes a key', async () => { + await state.deleteKey('users'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: {}, + encrypted: false, + }, + }); + }); + + it('deletes a nested key', async () => { + await state.deleteKey('users[0].age'); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: { + users: [ + { + name: 'John', + }, + { + name: 'Jane', + age: 25, + }, + ], + }, + encrypted: false, + }, + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/state/State.ts b/merged-packages/tron-wallet-snap/src/services/state/State.ts new file mode 100644 index 00000000..ab6db175 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/state/State.ts @@ -0,0 +1,262 @@ +import type { Transaction } from '@metamask/keyring-api'; +import type { MutexInterface } from 'async-mutex'; +import { Mutex } from 'async-mutex'; +import { unset } from 'lodash'; + +import type { IStateManager } from './IStateManager'; +import type { SpotPrices } from '../../clients/price-api/types'; +import type { AssetEntity } from '../../entities/assets'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { safeMerge } from '../../utils/safeMerge'; +import { deserialize } from '../../utils/serialization/deserialize'; +import { serialize } from '../../utils/serialization/serialize'; +import type { Serializable } from '../../utils/serialization/types'; + +export type AccountId = string; + +export type UnencryptedStateValue = { + keyringAccounts: Record; + assets: Record; + tokenPrices: SpotPrices; + transactions: Record; + mapInterfaceNameToId: Record; +}; + +export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { + keyringAccounts: {}, + assets: {}, + tokenPrices: {}, + transactions: {}, + mapInterfaceNameToId: {}, +}; + +export type StateConfig> = { + encrypted: boolean; + defaultState: TValue; +}; + +/** + * Because we use both snap_manageState and snap_setState, we must protect against them being used at the same time. + * We must also protect against multiple parallel requests to snap_manageState. + * snap_setState, snap_getState etc does not have this limitation and can be accessed safely as long as + * an ongoing manageState operation is not occurring. + */ +class StateLock { + readonly #blobModificationMutex = new Mutex(); + + readonly #regularStateUpdateMutex = new Mutex(); + + readonly #regularStateWriteMutex = new Mutex(); + + #pendingRegularStateUpdates = 0; + + #releaseRegularStateUpdateMutex: MutexInterface.Releaser | null = null; + + async #acquireRegularStateUpdateMutex(): Promise { + if (!this.#regularStateUpdateMutex.isLocked()) { + this.#releaseRegularStateUpdateMutex = + await this.#regularStateUpdateMutex.acquire(); + } + } + + async wrapRegularStateOperation( + callback: MutexInterface.Worker, + ): Promise { + // If we are currently doing a full blob update, wait it out. + // Signal that regular state operations are ongoing by acquring the mutex. + // Other regular state operations can skip this, as they are safe to do in parallel. + await Promise.all([ + this.#blobModificationMutex.waitForUnlock(), + this.#acquireRegularStateUpdateMutex(), + ]); + + try { + this.#pendingRegularStateUpdates += 1; + return await callback(); + } finally { + this.#pendingRegularStateUpdates -= 1; + + if ( + this.#pendingRegularStateUpdates === 0 && + this.#releaseRegularStateUpdateMutex + ) { + this.#releaseRegularStateUpdateMutex(); + } + } + } + + async wrapRegularStateWriteOperation( + callback: MutexInterface.Worker, + ): Promise { + return await this.#regularStateWriteMutex.runExclusive(async () => + this.wrapRegularStateOperation(callback), + ); + } + + async wrapManageStateOperation( + callback: MutexInterface.Worker, + ): Promise { + await this.#regularStateUpdateMutex.waitForUnlock(); + + return await this.#blobModificationMutex.runExclusive(callback); + } +} + +/** + * This class is a layer on top the the `snap_manageState` API that facilitates its usage: + * + * Basic usage: + * - Get and update the sate of the snap + * + * Serialization: + * - It serializes the data before storing it in the snap state because only JSON-assignable data can be stored. + * - It deserializes the data after retrieving it from the snap state. + * - So you don't need to worry about the data format when storing or retrieving data. + * + * Default values: + * - It merges the default state with the underlying snap state to ensure that we always have default values, + * letting us avoid a ton of null checks everywhere. + */ +export class State< + TStateValue extends Record, +> implements IStateManager { + readonly #lock = new StateLock(); + + readonly #config: StateConfig; + + constructor(config: StateConfig) { + this.#config = config; + } + + async #unsafeGet(): Promise { + const state = await snap.request({ + method: 'snap_getState', + params: { + encrypted: this.#config.encrypted, + }, + }); + + const stateDeserialized = deserialize(state ?? {}) as TStateValue; + + // Merge the default state with the underlying snap state + // to ensure that we always have default values. It lets us avoid a ton of null checks everywhere. + const stateWithDefaults = safeMerge( + this.#config.defaultState, + stateDeserialized, + ); + + return stateWithDefaults; + } + + async get(): Promise { + return this.#lock.wrapRegularStateOperation(async () => this.#unsafeGet()); + } + + async getKey( + key: string, + ): Promise { + return this.#lock.wrapRegularStateOperation(async () => { + const value = await snap.request({ + method: 'snap_getState', + params: { + key, + encrypted: this.#config.encrypted, + }, + }); + + if (value === null) { + return undefined; + } + + return deserialize(value) as TResponse; + }); + } + + async setKey(key: string, value: Serializable): Promise { + await this.#lock.wrapRegularStateWriteOperation(async () => { + const serializedValue = serialize(value); + + await snap.request({ + method: 'snap_setState', + params: { + key, + value: serializedValue, + encrypted: this.#config.encrypted, + }, + }); + }); + } + + async setKeyWith( + key: string, + updater: (currentValue: TValue | undefined) => TValue, + ): Promise { + await this.#lock.wrapRegularStateWriteOperation(async () => { + const rawValue = await snap.request({ + method: 'snap_getState', + params: { + key, + encrypted: this.#config.encrypted, + }, + }); + + const oldValue = + rawValue === null ? undefined : (deserialize(rawValue) as TValue); + + const newValue = updater(oldValue); + + const serializedValue = serialize(newValue); + + await snap.request({ + method: 'snap_setState', + params: { + key, + value: serializedValue, + encrypted: this.#config.encrypted, + }, + }); + }); + } + + async update( + updaterFunction: (state: TStateValue) => TStateValue, + ): Promise { + // Because this function modifies the entire state blob, + // we must protect against parallel requests. + return await this.#lock.wrapManageStateOperation(async () => { + const currentState = await this.#unsafeGet(); + + const newState = updaterFunction(currentState); + + // Generally we should try to use snap_getState and snap_setState over this + // as snap_manageState is slower and error-prone due to requiring manual mutex management. + await snap.request({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: serialize(newState), + encrypted: this.#config.encrypted, + }, + }); + + return newState; + }); + } + + async deleteKey(key: string): Promise { + await this.update((state) => { + // Using lodash's unset to leverage the json path capabilities + unset(state, key); + return state; + }); + } + + async deleteKeys(keys: string[]): Promise { + await this.update((state) => { + keys.forEach((key) => { + unset(state, key); + }); + return state; + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transaction-expiration-refresher/TransactionExpirationRefresherService.ts b/merged-packages/tron-wallet-snap/src/services/transaction-expiration-refresher/TransactionExpirationRefresherService.ts new file mode 100644 index 00000000..d24a33ba --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transaction-expiration-refresher/TransactionExpirationRefresherService.ts @@ -0,0 +1,462 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { bytesToHex, hexToBytes, sha256 } from '@metamask/utils'; +import type { TronWeb, Types } from 'tronweb'; +import type { Block } from 'tronweb/lib/esm/types/APIResponse'; + +import type { + HasFreshExpirationMetadataParams, + HasFreshExpirationMetadataResult, + TransactionRawData, + TransactionRawDataWithExpirationMetadata, + TransactionWithMetadata, +} from './types'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import type { Network } from '../../constants'; + +/** + * TRON block time is 3 seconds, so refresh if expiration is within the + * documented 1-3 block interval window. + * https://developers.tron.network/docs/create-offline-transactions-with-trident-and-tronweb#block-related-field-descriptions + */ +export const TRANSACTION_METADATA_EXPIRATION_BUFFER_MS = 9_000; +export const TRANSACTION_METADATA_REFRESH_ERROR = + 'Unable to refresh transaction metadata before signing. Please rebuild the transaction and try again.'; + +/** + * TRON TAPOS reference blocks are valid within the latest 65,536 blocks. + * https://developers.tron.network/docs/tron-protocol-transaction + */ +const REFERENCE_BLOCK_WINDOW = 65_536; + +/** + * Match TronWeb's getCurrentRefBlockParams default: block timestamp + 60s. + * https://github.com/tronprotocol/tronweb/blob/v6.1.0/src/lib/trx.ts#L1497-L1499 + */ +const DEFAULT_EXPIRATION_MS = 60_000; +const MAX_EXPIRATION_MS = 24 * 60 * 60 * 1000; + +/** + * Refreshes transaction expiration and TAPOS metadata for: + * - Full unsigned transactions that are about to be signed. + * - raw_data objects stored in send-confirmation UI context for rescans. + * - Serialized rawDataHex transactions stored in sign-transaction UI context. + */ +export class TransactionExpirationRefresherService { + readonly #tronWebFactory: TronWebFactory; + + constructor({ tronWebFactory }: { tronWebFactory: TronWebFactory }) { + this.#tronWebFactory = tronWebFactory; + } + + /** + * Ensures serialized TRON transaction raw data has fresh TAPOS and expiration + * metadata, returning the transaction fields needed by confirmation scans. + * + * @param params - Refresh inputs. + * @param params.scope - Network scope used to create a TronWeb client. + * @param params.type - Serialized transaction contract type. + * @param params.rawDataHex - Serialized transaction raw data. + * @returns Transaction metadata built from the serialized raw data. + */ + async ensureFreshSerializedTransaction({ + scope, + type, + rawDataHex, + }: { + scope: Network; + type: string; + rawDataHex: string; + }): Promise { + try { + const tronWeb = this.#tronWebFactory.createClient(scope); + const rawData = tronWeb.utils.deserializeTx.deserializeTransaction( + type, + rawDataHex, + ) as Types.Transaction['raw_data']; + const txID = bytesToHex(await sha256(hexToBytes(rawDataHex))).slice(2); + const transaction = { + txID, + raw_data: rawData, + raw_data_hex: rawDataHex, + }; + const freshRawData = await this.#ensureFreshRawData(tronWeb, rawData); + + if (freshRawData === rawData) { + return transaction; + } + + return this.#rebuildTransactionWithRawData( + tronWeb, + transaction, + freshRawData, + ); + } catch { + throw new Error(TRANSACTION_METADATA_REFRESH_ERROR); + } + } + + /** + * Ensures TRON transaction TAPOS and expiration metadata is usable + * immediately before signing and broadcasting. + * + * @param params - Refresh inputs. + * @param params.scope - Network scope used to create a TronWeb client. + * @param params.transaction - Unsigned transaction to validate and refresh. + * @returns The transaction to sign. + */ + async ensureFreshMetadata({ + scope, + transaction, + }: { + scope: Network; + transaction: TransactionType; + }): Promise { + try { + const tronWeb = this.#tronWebFactory.createClient(scope); + const freshRawData = await this.#ensureFreshRawData( + tronWeb, + transaction.raw_data, + ); + + if (freshRawData === transaction.raw_data) { + return transaction; + } + + return this.#rebuildTransactionWithRawData( + tronWeb, + transaction, + freshRawData, + ); + } catch { + throw new Error(TRANSACTION_METADATA_REFRESH_ERROR); + } + } + + /** + * Ensures TRON transaction raw data has fresh TAPOS and expiration metadata. + * + * @param params - Refresh inputs. + * @param params.scope - Network scope used to create a TronWeb client. + * @param params.rawData - Transaction raw data to validate and refresh. + * @returns The raw data to scan or sign. + */ + async ensureFreshRawData({ + scope, + rawData, + }: { + scope: Network; + rawData: RawDataType; + }): Promise { + try { + return await this.#ensureFreshRawData( + this.#tronWebFactory.createClient(scope), + rawData, + ); + } catch { + throw new Error(TRANSACTION_METADATA_REFRESH_ERROR); + } + } + + async #ensureFreshRawData( + tronWeb: TronWeb, + rawData: RawDataType, + ): Promise { + try { + const now = Date.now(); + const currentBlock = await tronWeb.trx.getCurrentBlock(); + const expirationMetadataParams = { + currentBlock, + now, + rawData, + }; + + if (!hasFreshExpirationMetadata(expirationMetadataParams)) { + return buildRefreshedRawData(rawData, currentBlock); + } + + const referenceBlockNumber = getReferenceBlockNumber({ + currentBlockNumber: currentBlock.block_header.raw_data.number, + refBlockBytes: expirationMetadataParams.rawData.ref_block_bytes, + }); + + if ( + !hasValidReferenceBlockNumber({ + currentBlock, + referenceBlockNumber, + }) + ) { + return buildRefreshedRawData(rawData, currentBlock); + } + + const referencedBlock = await this.#getReferenceBlock({ + tronWeb, + currentBlock, + referenceBlockNumber, + }); + + if ( + !referencedBlockMatchesRawData({ + rawData: expirationMetadataParams.rawData, + referencedBlock, + }) + ) { + return buildRefreshedRawData(rawData, currentBlock); + } + + return rawData; + } catch { + throw new Error(TRANSACTION_METADATA_REFRESH_ERROR); + } + } + + async #getReferenceBlock({ + tronWeb, + currentBlock, + referenceBlockNumber, + }: { + tronWeb: TronWeb; + currentBlock: Block; + referenceBlockNumber: number; + }): Promise { + const currentBlockNumber = currentBlock.block_header.raw_data.number; + + if (referenceBlockNumber === currentBlockNumber) { + return currentBlock; + } + + try { + return await tronWeb.trx.getBlockByNumber(referenceBlockNumber); + } catch { + return undefined; + } + } + + #rebuildTransactionWithRawData< + TransactionType extends TransactionWithMetadata, + >( + tronWeb: TronWeb, + transaction: TransactionType, + rawData: TransactionRawData, + ): TransactionType { + const refreshedTransaction = { + ...transaction, + raw_data: rawData, + }; + const transactionPb = + tronWeb.utils.transaction.txJsonToPb(refreshedTransaction); + + return { + ...refreshedTransaction, + raw_data_hex: tronWeb.utils.transaction.txPbToRawDataHex(transactionPb), + txID: tronWeb.utils.transaction + .txPbToTxID(transactionPb) + .replace(/^0x/u, ''), + }; + } +} + +/** + * Builds raw data with expiration and TAPOS metadata from the current block. + * + * @param rawData - Transaction raw data to refresh. + * @param currentBlock - Current network block. + * @returns Raw data with refreshed expiration metadata. + */ +function buildRefreshedRawData( + rawData: RawDataType, + currentBlock: Block, +): RawDataType { + const { number: currentBlockNumber, timestamp: currentBlockTimestamp } = + currentBlock.block_header.raw_data; + + return { + ...rawData, + ref_block_bytes: getRefBlockBytes(currentBlockNumber), + ref_block_hash: getRefBlockHash(currentBlock), + expiration: currentBlockTimestamp + DEFAULT_EXPIRATION_MS, + timestamp: currentBlockTimestamp, + }; +} + +/** + * Checks whether raw data has expiration metadata that is safe to keep. + * + * @param params - Expiration freshness inputs. + * @returns Whether the existing expiration metadata is fresh. + */ +function hasFreshExpirationMetadata( + params: HasFreshExpirationMetadataParams, +): params is HasFreshExpirationMetadataResult { + return ( + hasRequiredExpirationMetadata(params.rawData) && + hasFreshExpiration({ + currentBlock: params.currentBlock, + now: params.now, + rawData: params.rawData, + }) && + hasAllowedExpirationWindow({ + currentBlock: params.currentBlock, + rawData: params.rawData, + }) + ); +} + +/** + * Checks whether raw data has the metadata needed for freshness validation. + * + * @param rawData - Transaction raw data to inspect. + * @returns Whether required expiration metadata is present. + */ +function hasRequiredExpirationMetadata( + rawData: TransactionRawData, +): rawData is TransactionRawDataWithExpirationMetadata { + return ( + typeof rawData.ref_block_bytes === 'string' && + typeof rawData.ref_block_hash === 'string' && + typeof rawData.expiration === 'number' + ); +} + +/** + * Checks whether expiration leaves enough time for signing and broadcasting. + * + * @param options0 - Freshness check inputs. + * @param options0.currentBlock - Current network block. + * @param options0.now - Current local timestamp in milliseconds. + * @param options0.rawData - Transaction raw data with expiration metadata. + * @returns Whether expiration is outside the refresh buffer. + */ +function hasFreshExpiration({ + currentBlock, + now, + rawData, +}: { + currentBlock: Block; + now: number; + rawData: TransactionRawDataWithExpirationMetadata; +}): boolean { + const currentBlockTimestamp = currentBlock.block_header.raw_data.timestamp; + + return ( + rawData.expiration > + Math.max(now, currentBlockTimestamp) + + TRANSACTION_METADATA_EXPIRATION_BUFFER_MS + ); +} + +/** + * Checks whether expiration is within TRON's maximum transaction window. + * + * @param options0 - Expiration window inputs. + * @param options0.currentBlock - Current network block. + * @param options0.rawData - Transaction raw data with expiration metadata. + * @returns Whether expiration is within the maximum allowed window. + */ +function hasAllowedExpirationWindow({ + currentBlock, + rawData, +}: { + currentBlock: Block; + rawData: TransactionRawDataWithExpirationMetadata; +}): boolean { + const currentBlockTimestamp = currentBlock.block_header.raw_data.timestamp; + + return rawData.expiration < currentBlockTimestamp + MAX_EXPIRATION_MS; +} + +/** + * Reconstructs the referenced block number from TAPOS lower bytes. + * + * @param options0 - Reference block number inputs. + * @param options0.currentBlockNumber - Current network block number. + * @param options0.refBlockBytes - Lower bytes from transaction TAPOS metadata. + * @returns Referenced block number candidate. + */ +function getReferenceBlockNumber({ + currentBlockNumber, + refBlockBytes, +}: { + currentBlockNumber: number; + refBlockBytes: string; +}): number { + const refBlockLowerBytes = Number.parseInt(refBlockBytes, 16); + const currentWindowStart = + Math.floor(currentBlockNumber / REFERENCE_BLOCK_WINDOW) * + REFERENCE_BLOCK_WINDOW; + const referenceBlockCandidate = currentWindowStart + refBlockLowerBytes; + + return referenceBlockCandidate > currentBlockNumber + ? referenceBlockCandidate - REFERENCE_BLOCK_WINDOW + : referenceBlockCandidate; +} + +/** + * Checks whether the referenced block number is inside the TAPOS window. + * + * @param options0 - Reference block validation inputs. + * @param options0.currentBlock - Current network block. + * @param options0.referenceBlockNumber - Referenced block number candidate. + * @returns Whether the referenced block number can be used. + */ +function hasValidReferenceBlockNumber({ + currentBlock, + referenceBlockNumber, +}: { + currentBlock: Block; + referenceBlockNumber: number; +}): boolean { + const currentBlockNumber = currentBlock.block_header.raw_data.number; + + return ( + Number.isFinite(referenceBlockNumber) && + referenceBlockNumber >= 0 && + referenceBlockNumber <= currentBlockNumber && + currentBlockNumber - referenceBlockNumber < REFERENCE_BLOCK_WINDOW + ); +} + +/** + * Checks whether fetched block metadata matches the transaction TAPOS fields. + * + * @param options0 - Reference block matching inputs. + * @param options0.rawData - Transaction raw data with TAPOS metadata. + * @param options0.referencedBlock - Block fetched for the TAPOS reference. + * @returns Whether the referenced block matches the raw data metadata. + */ +function referencedBlockMatchesRawData({ + rawData, + referencedBlock, +}: { + rawData: TransactionRawDataWithExpirationMetadata; + referencedBlock: Block | undefined; +}): boolean { + if (!referencedBlock) { + return false; + } + + return ( + getRefBlockBytes(referencedBlock.block_header.raw_data.number) === + rawData.ref_block_bytes.toLowerCase() && + getRefBlockHash(referencedBlock) === rawData.ref_block_hash.toLowerCase() + ); +} + +/** + * Gets TRON TAPOS lower block bytes for a block number. + * + * @param blockNumber - Block number to encode. + * @returns TAPOS lower block bytes. + */ +function getRefBlockBytes(blockNumber: number): string { + return blockNumber.toString(16).slice(-4).padStart(4, '0'); +} + +/** + * Gets TRON TAPOS block hash segment from a block. + * + * @param block - Block to inspect. + * @returns TAPOS block hash segment. + */ +function getRefBlockHash(block: Block): string { + return block.blockID.slice(16, 32).toLowerCase(); +} diff --git a/merged-packages/tron-wallet-snap/src/services/transaction-expiration-refresher/types.ts b/merged-packages/tron-wallet-snap/src/services/transaction-expiration-refresher/types.ts new file mode 100644 index 00000000..bd8c1c86 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transaction-expiration-refresher/types.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Json } from '@metamask/snaps-sdk'; +import type { Types } from 'tronweb'; +import type { Block } from 'tronweb/lib/esm/types/APIResponse'; + +export type TransactionRawData = Types.Transaction['raw_data']; + +export type JsonTransactionRawData = TransactionRawData & { + [prop: string]: Json; +}; + +export type TransactionRawDataWithExpirationMetadata = TransactionRawData & { + expiration: number; + ref_block_bytes: string; + ref_block_hash: string; +}; + +export type TransactionWithMetadata< + RawDataType extends TransactionRawData = TransactionRawData, +> = { + raw_data: RawDataType; + raw_data_hex: string; + txID: string; +}; + +export type HasFreshExpirationMetadataParams = { + currentBlock: Block; + now: number; + rawData: TransactionRawData; +}; + +export type HasFreshExpirationMetadataResult = + HasFreshExpirationMetadataParams & { + rawData: TransactionRawDataWithExpirationMetadata; + }; diff --git a/merged-packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts b/merged-packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts new file mode 100644 index 00000000..1086dc0b --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts @@ -0,0 +1,565 @@ +import { Types } from 'tronweb'; + +import { TransactionScanService } from './TransactionScanService'; +import type { SecurityAlertsApiClient } from '../../clients/security-alerts-api/SecurityAlertsApiClient'; +import type { SecurityAlertSimulationValidationResponse } from '../../clients/security-alerts-api/structs'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import { Network } from '../../constants'; +import { mockLogger } from '../../utils/mockLogger'; + +describe('TransactionScanService', () => { + const createMockSecurityAlertsApiClient = ( + mockApiResponse: SecurityAlertSimulationValidationResponse, + ): jest.Mocked> => ({ + scanTransaction: jest.fn().mockResolvedValue(mockApiResponse), + }); + + const createMockSnapClient = (): jest.Mocked< + Pick + > => ({ + trackSecurityScanCompleted: jest.fn(), + trackError: jest.fn(), + }); + + const createWellFormedTransactionRawData = + (): Types.Transaction['raw_data'] => ({ + contract: [ + { + type: Types.ContractType.TransferContract, + parameter: { + // eslint-disable-next-line @typescript-eslint/naming-convention + type_url: 'type.googleapis.com/protocol.TransferContract', + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: `41${'a'.repeat(40)}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + to_address: `41${'b'.repeat(40)}`, + amount: 990000, + }, + }, + }, + ], + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_bytes: '0000', + // eslint-disable-next-line @typescript-eslint/naming-convention + ref_block_hash: '0'.repeat(16), + expiration: Date.now() + 60000, + timestamp: Date.now(), + }); + + describe('estimated changes decimal precision', () => { + it('computes display value from raw_value and decimals', async () => { + const mockApiResponse: SecurityAlertSimulationValidationResponse = { + simulation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + account_summary: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assets_diffs: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: 'NATIVE', + asset: { + type: 'NATIVE', + symbol: 'TRX', + name: 'Tronix', + decimals: 6, + }, + in: [], + out: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + usd_price: '0.31', + summary: '', + // Simulates the API returning an imprecise float-to-string value + value: '0.98999999999999991', + // The raw integer value in smallest unit (sun) is exact + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_value: '990000', + }, + ], + }, + ], + }, + }, + validation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + result_type: 'Benign', + }, + }; + + const mockSecurityAlertsApiClient = + createMockSecurityAlertsApiClient(mockApiResponse); + const mockSnapClient = createMockSnapClient(); + + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + mockSnapClient as unknown as SnapClient, + mockLogger, + ); + + const result = await service.scanTransaction({ + accountAddress: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://tronscan.on.btfs.io', + scope: Network.Mainnet, + options: ['simulation'], + }); + + expect(result?.estimatedChanges.assets[0]?.value).toBe('0.99'); + }); + + it('falls back to "0" value when decimals is missing', async () => { + const mockApiResponse: SecurityAlertSimulationValidationResponse = { + simulation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + account_summary: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assets_diffs: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: 'NATIVE', + asset: { + type: 'NATIVE', + symbol: 'TRX', + name: 'Tronix', + // decimals is missing + }, + in: [], + out: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + usd_price: '0.31', + summary: '', + value: '0.99', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_value: '990000', + }, + ], + }, + ], + }, + }, + validation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + result_type: 'Benign', + }, + }; + + const mockSecurityAlertsApiClient = + createMockSecurityAlertsApiClient(mockApiResponse); + const mockSnapClient = createMockSnapClient(); + + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + mockSnapClient as unknown as SnapClient, + mockLogger, + ); + + const result = await service.scanTransaction({ + accountAddress: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://tronscan.on.btfs.io', + scope: Network.Mainnet, + options: ['simulation'], + }); + + // Falls back to "0" value when decimals is missing + expect(result?.estimatedChanges.assets[0]?.value).toBe('0'); + }); + + it('falls back to "0" value when raw_value is missing', async () => { + const mockApiResponse: SecurityAlertSimulationValidationResponse = { + simulation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + account_summary: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assets_diffs: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: 'NATIVE', + asset: { + type: 'NATIVE', + symbol: 'TRX', + name: 'Tronix', + decimals: 6, + }, + in: [], + out: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + usd_price: '0.31', + summary: '', + value: '0.99', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_value: '', + }, + ], + }, + ], + }, + }, + validation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + result_type: 'Benign', + }, + }; + + const mockSecurityAlertsApiClient = + createMockSecurityAlertsApiClient(mockApiResponse); + const mockSnapClient = createMockSnapClient(); + + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + mockSnapClient as unknown as SnapClient, + mockLogger, + ); + + const result = await service.scanTransaction({ + accountAddress: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://tronscan.on.btfs.io', + scope: Network.Mainnet, + options: ['simulation'], + }); + + expect(result?.estimatedChanges.assets[0]?.value).toBe('0'); + }); + + it('handles decimals of 0 correctly (no decimal shift)', async () => { + const mockApiResponse: SecurityAlertSimulationValidationResponse = { + simulation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + account_summary: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assets_diffs: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: 'TRC10', + asset: { + type: 'TRC10', + symbol: 'BTT', + name: 'BitTorrent', + decimals: 0, + }, + in: [], + out: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + usd_price: '0.001', + summary: '', + value: '1000', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_value: '1000', + }, + ], + }, + ], + }, + }, + validation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + result_type: 'Benign', + }, + }; + + const mockSecurityAlertsApiClient = + createMockSecurityAlertsApiClient(mockApiResponse); + const mockSnapClient = createMockSnapClient(); + + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + mockSnapClient as unknown as SnapClient, + mockLogger, + ); + + const result = await service.scanTransaction({ + accountAddress: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://tronscan.on.btfs.io', + scope: Network.Mainnet, + options: ['simulation'], + }); + + // 10^0 = 1, so raw_value / 1 = raw_value unchanged + expect(result?.estimatedChanges.assets[0]?.value).toBe('1000'); + }); + + it('computes precise value for TRC20 tokens with 18 decimals', async () => { + const mockApiResponse: SecurityAlertSimulationValidationResponse = { + simulation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + account_summary: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assets_diffs: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: 'ERC20', + asset: { + type: 'ERC20', + symbol: 'WTRX', + name: 'Wrapped TRX', + decimals: 18, + }, + in: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + usd_price: '0.31', + summary: '', + value: '1.49999999999999999', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_value: '1500000000000000000', + }, + ], + out: [], + }, + ], + }, + }, + validation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + result_type: 'Benign', + }, + }; + + const mockSecurityAlertsApiClient = + createMockSecurityAlertsApiClient(mockApiResponse); + const mockSnapClient = createMockSnapClient(); + + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + mockSnapClient as unknown as SnapClient, + mockLogger, + ); + + const result = await service.scanTransaction({ + accountAddress: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://tronscan.on.btfs.io', + scope: Network.Mainnet, + options: ['simulation'], + }); + + // 18-decimal token: 1500000000000000000 / 10^18 = 1.5 (exact) + expect(result?.estimatedChanges.assets[0]?.value).toBe('1.5'); + expect(result?.estimatedChanges.assets[0]?.type).toBe('in'); + }); + + it('maps ERC721 NFT asset changes with token_id', async () => { + const mockApiResponse: SecurityAlertSimulationValidationResponse = { + simulation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + account_summary: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assets_diffs: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: 'NATIVE', + asset: { + type: 'NATIVE', + symbol: 'TRX', + name: 'TRX', + decimals: 6, + }, + in: [], + out: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + usd_price: '0.315', + summary: 'Sending 1 TRX', + value: '1.0', + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_value: '0xf4240', + }, + ], + }, + { + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: 'ERC721', + asset: { + type: 'ERC721', + symbol: 'SUN-V3-POS', + name: 'Sunswap V3 Positions NFT-V1', + // eslint-disable-next-line @typescript-eslint/naming-convention + logo_url: + 'https://cdn.blockaid.io/nft/0x72DB65b2e023E4783D46023e7135c692E527F6CB/tron/sec/example', + }, + in: [ + { + summary: 'Receiving Sunswap V3 Positions NFT-V1 #1495', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_id: '0x5d7', + // eslint-disable-next-line @typescript-eslint/naming-convention + arbitrary_collection_token: false, + // eslint-disable-next-line @typescript-eslint/naming-convention + logo_url: + 'https://cdn.blockaid.io/nft/0x72DB65b2e023E4783D46023e7135c692E527F6CB/1495/tron/sec/example', + }, + ], + out: [], + }, + ], + }, + }, + validation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + result_type: 'Benign', + }, + }; + + const mockSecurityAlertsApiClient = + createMockSecurityAlertsApiClient(mockApiResponse); + const mockSnapClient = createMockSnapClient(); + + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + mockSnapClient as unknown as SnapClient, + mockLogger, + ); + + const result = await service.scanTransaction({ + accountAddress: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://tm2.sun.io', + scope: Network.Mainnet, + options: ['simulation'], + }); + + expect(result?.status).toBe('SUCCESS'); + expect(result?.estimatedChanges.assets).toHaveLength(2); + + // Native TRX out + expect(result?.estimatedChanges.assets[0]).toStrictEqual({ + type: 'out', + symbol: 'TRX', + name: 'TRX', + logo: null, + value: '1', + price: '0.315', + assetType: 'NATIVE', + }); + + // ERC721 NFT in — value should be "1" for a single NFT + expect(result?.estimatedChanges.assets[1]).toStrictEqual({ + type: 'in', + symbol: 'SUN-V3-POS', + name: 'Sunswap V3 Positions NFT-V1', + logo: 'https://cdn.blockaid.io/nft/0x72DB65b2e023E4783D46023e7135c692E527F6CB/tron/sec/example', + value: '1', + price: null, + assetType: 'ERC721', + }); + }); + + it('maps ERC1155 asset changes with token_id and value', async () => { + const mockApiResponse: SecurityAlertSimulationValidationResponse = { + simulation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + account_summary: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assets_diffs: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: 'ERC1155', + asset: { + type: 'ERC1155', + symbol: 'ITEM', + name: 'Game Item', + }, + in: [], + out: [ + { + summary: 'Sending 5 Game Item', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_id: '0x1', + value: '5', + // eslint-disable-next-line @typescript-eslint/naming-convention + arbitrary_collection_token: false, + // eslint-disable-next-line @typescript-eslint/naming-convention + usd_price: '10.00', + }, + ], + }, + ], + }, + }, + validation: { + status: 'Success', + // eslint-disable-next-line @typescript-eslint/naming-convention + result_type: 'Benign', + }, + }; + + const mockSecurityAlertsApiClient = + createMockSecurityAlertsApiClient(mockApiResponse); + const mockSnapClient = createMockSnapClient(); + + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + mockSnapClient as unknown as SnapClient, + mockLogger, + ); + + const result = await service.scanTransaction({ + accountAddress: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://example.com', + scope: Network.Mainnet, + options: ['simulation'], + }); + + expect(result?.estimatedChanges.assets[0]).toStrictEqual({ + type: 'out', + symbol: 'ITEM', + name: 'Game Item', + logo: null, + value: '5', + price: '10.00', + assetType: 'ERC1155', + }); + }); + }); + + describe('failed transaction scan', () => { + it('tracks the error', async () => { + const error = new Error('Scan failed'); + + const mockSecurityAlertsApiClient = createMockSecurityAlertsApiClient({ + simulation: { status: 'Success' }, + // eslint-disable-next-line @typescript-eslint/naming-convention + validation: { status: 'Success', result_type: 'Benign' }, + }); + mockSecurityAlertsApiClient.scanTransaction.mockRejectedValueOnce(error); + + const mockSnapClient = createMockSnapClient(); + + const service = new TransactionScanService( + mockSecurityAlertsApiClient as unknown as SecurityAlertsApiClient, + mockSnapClient as unknown as SnapClient, + mockLogger, + ); + + await service.scanTransaction({ + accountAddress: 'TExvJsxzPyAZ2NtkrWgNKnbLkpqnFJ73DT', + transactionRawData: createWellFormedTransactionRawData(), + origin: 'https://tronscan.on.btfs.io', + scope: Network.Mainnet, + options: ['simulation'], + }); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts b/merged-packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts new file mode 100644 index 00000000..9487a178 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts @@ -0,0 +1,392 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { BigNumber } from 'bignumber.js'; +import type { Types } from 'tronweb'; + +import type { + TransactionScanAssetChange, + TransactionScanError, + TransactionScanResult, + TransactionScanValidation, +} from './types'; +import { ScanStatus, SecurityAlertResponse, SimulationStatus } from './types'; +import { SecurityAlertsApiClient } from '../../clients/security-alerts-api/SecurityAlertsApiClient'; +import type { + AssetChange, + AssetDiff, + SecurityAlertSimulationValidationResponse, +} from '../../clients/security-alerts-api/structs'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import type { ILogger } from '../../utils/logger'; +import { isTransactionWellFormed } from '../../validation/transaction'; + +const METAMASK_ORIGIN = 'metamask'; +const METAMASK_ORIGIN_URL = 'https://metamask.io'; + +export class TransactionScanService { + readonly #securityAlertsApiClient: SecurityAlertsApiClient; + + readonly #snapClient: SnapClient; + + readonly #logger: ILogger; + + constructor( + securityAlertsApiClient: SecurityAlertsApiClient, + snapClient: SnapClient, + logger: ILogger, + ) { + this.#securityAlertsApiClient = securityAlertsApiClient; + this.#snapClient = snapClient; + this.#logger = logger; + } + + /** + * Scans a Tron transaction for security issues. + * + * @param params - The parameters for the function. + * @param params.accountAddress - The address of the account. + * @param params.transactionRawData - The raw data of the transaction. + * @param params.origin - The origin of the transaction. + * @param params.scope - The network scope. + * @param params.options - The options for the scan (simulation, validation). + * @param params.account - The account for analytics tracking. + * @returns The result of the scan, or null if the scan failed. + */ + async scanTransaction({ + accountAddress, + transactionRawData, + origin, + scope, + options = ['simulation', 'validation'], + account, + }: { + accountAddress: string; + transactionRawData: Types.Transaction['raw_data']; + origin: string; + scope: Network; + options?: string[] | undefined; + account?: TronKeyringAccount; + }): Promise { + if (!isTransactionWellFormed(transactionRawData)) { + this.#logger.warn( + 'Malformed transaction: Tron transactions must contain exactly one contract', + ); + + return { + status: 'ERROR', + estimatedChanges: { assets: [] }, + validation: { type: null, reason: null }, + error: { + type: 'MALFORMED_TRANSACTION', + code: null, + message: 'Tron transactions must contain exactly one contract entry.', + }, + simulationStatus: SimulationStatus.Failed, + }; + } + + if (!SecurityAlertsApiClient.isContractTypeSupported(transactionRawData)) { + this.#logger.info( + 'Transaction contract type is not supported for simulation, skipping scan', + ); + + return { + status: 'SUCCESS', + estimatedChanges: { assets: [] }, + validation: { type: null, reason: null }, + error: null, + simulationStatus: SimulationStatus.Skipped, + }; + } + + try { + const result = await this.#securityAlertsApiClient.scanTransaction({ + accountAddress, + transactionRawData, + origin: origin === METAMASK_ORIGIN ? METAMASK_ORIGIN_URL : origin, + options, + }); + + const scan = this.#mapScan(result); + + if (!scan?.status) { + this.#logger.warn( + 'Invalid scan result received from security alerts API', + ); + + // Track error if account is provided + if (account) { + await this.#snapClient.trackSecurityScanCompleted({ + origin, + accountType: account.type, + chainIdCaip: scope, + scanStatus: ScanStatus.ERROR, + hasSecurityAlerts: false, + }); + } + + return null; + } + + // Track security scan completion + if (account) { + const isValidScanStatus = Object.values(ScanStatus).includes( + scan.status as ScanStatus, + ); + const scanStatus = isValidScanStatus + ? (scan.status as ScanStatus) + : ScanStatus.ERROR; + + const hasSecurityAlert = Boolean( + scan.validation?.type && + scan.validation.type !== SecurityAlertResponse.Benign, + ); + + // Track scan completed + await this.#snapClient.trackSecurityScanCompleted({ + origin, + accountType: account.type, + chainIdCaip: scope, + scanStatus, + hasSecurityAlerts: hasSecurityAlert, + }); + + // Track security alert if detected + if (hasSecurityAlert) { + const isValidSecurityAlertType = Object.values( + SecurityAlertResponse, + ).includes(scan.validation.type as SecurityAlertResponse); + const securityAlertType = isValidSecurityAlertType + ? (scan.validation.type as SecurityAlertResponse) + : SecurityAlertResponse.Warning; + + await this.#snapClient.trackSecurityAlertDetected({ + origin, + accountType: account.type, + chainIdCaip: scope, + securityAlertResponse: securityAlertType, + securityAlertReason: scan.validation.reason ?? null, + securityAlertDescription: this.getSecurityAlertDescription( + scan.validation, + ), + }); + } + } + + return scan; + } catch (error) { + await this.#snapClient.trackError(error as Error); + this.#logger.error(error); + + // Track error if account is provided + if (account) { + await this.#snapClient.trackSecurityScanCompleted({ + origin, + accountType: account.type, + chainIdCaip: scope, + scanStatus: ScanStatus.ERROR, + hasSecurityAlerts: false, + }); + } + + return null; + } + } + + /** + * Gets a human-readable description for a security alert. + * + * @param validation - The validation result from the scan. + * @returns A description of the security alert. + */ + getSecurityAlertDescription(validation: TransactionScanValidation): string { + if (!validation?.reason) { + return 'Security alert: Unknown reason'; + } + + // Tron-specific reason descriptions + const reasonDescriptions: Record = { + unfair_trade: + "Unfair trade of assets, without adequate compensation to the owner's account", + transfer_farming: + "Substantial transfer of the account's assets to untrusted entities", + known_attacker: + "A known attacker's account is involved in the transaction", + other: + 'The transaction was marked as malicious for other reason, further details would be described in features field', + }; + + return ( + reasonDescriptions[validation.reason] ?? + `Security alert: ${validation.reason}` + ); + } + + /** + * Maps the raw API response to our internal scan result format. + * + * @param result - The raw API response. + * @returns The mapped scan result. + */ + #mapScan( + result: SecurityAlertSimulationValidationResponse, + ): TransactionScanResult | null { + if (!result) { + return null; + } + + const status = this.#resolveStatus(result); + + return { + status, + estimatedChanges: { + assets: this.#mapAssetDiffs( + result.simulation?.account_summary?.assets_diffs, + ), + }, + validation: { + type: result.validation?.result_type ?? null, + reason: result.validation?.reason ?? null, + }, + error: this.#mapSimulationError(result.simulation), + simulationStatus: + status === 'ERROR' + ? SimulationStatus.Failed + : SimulationStatus.Completed, + }; + } + + /** + * Resolves the scan status from API simulation and validation statuses. + * Returns ERROR only if either status is explicitly 'Error'. + * + * @param result - The raw API response. + * @returns The resolved scan status. + */ + #resolveStatus( + result: SecurityAlertSimulationValidationResponse, + ): TransactionScanResult['status'] { + return result.simulation?.status === 'Error' || + result.validation?.status === 'Error' + ? 'ERROR' + : 'SUCCESS'; + } + + /** + * Maps raw asset diffs from the API into internal asset change representations. + * + * @param assetDiffs - The raw asset diffs from the simulation. + * @returns The mapped asset changes. + */ + #mapAssetDiffs( + assetDiffs: AssetDiff[] | undefined, + ): TransactionScanAssetChange[] { + if (!assetDiffs) { + return []; + } + + return assetDiffs + .filter((asset) => this.#hasDisplayableChange(asset)) + .map((asset) => this.#mapAssetDiff(asset)); + } + + /** + * Checks whether an asset diff has a displayable change (value or token_id). + * + * @param asset - The asset diff to check. + * @returns True if the asset diff has a displayable change. + */ + #hasDisplayableChange(asset: AssetDiff): boolean { + const change = asset.in?.[0] ?? asset.out?.[0]; + if (!change) { + return false; + } + // NFT types (ERC721/ERC1155) use token_id instead of value + if ('token_id' in change) { + return true; + } + return change?.value !== undefined; + } + + /** + * Maps a single asset diff to our internal asset change format. + * + * @param asset - The asset diff to map. + * @returns The mapped asset change. + */ + #mapAssetDiff(asset: AssetDiff): TransactionScanAssetChange { + const inChange = asset.in?.[0]; + const outChange = asset.out?.[0]; + const change = inChange ?? outChange; + + return { + type: inChange ? ('in' as const) : ('out' as const), + symbol: asset.asset.symbol ?? asset.asset_type, + name: asset.asset.name ?? asset.asset_type, + logo: asset.asset.logo_url ?? null, + value: this.#computeDisplayValue(change, asset.asset.decimals), + price: change?.usd_price ?? null, + assetType: asset.asset_type, + }; + } + + /** + * Computes the human-readable display value for an asset change. + * Handles NFT types (ERC721/ERC1155) and fungible tokens differently. + * + * @param change - The asset change from the API. + * @param decimals - The token decimals (for fungible tokens). + * @returns The display value string. + */ + #computeDisplayValue( + change: AssetChange | undefined, + decimals: number | undefined, + ): string { + if (!change) { + return '0'; + } + + if ('token_id' in change) { + // NFT assets (ERC721): each token is a single unit + // ERC1155: use the value field if present + return 'value' in change && typeof change.value === 'string' + ? change.value + : '1'; + } + + if (change.raw_value && decimals !== undefined) { + return new BigNumber(change.raw_value) + .dividedBy(new BigNumber(10).pow(decimals)) + .toFixed(); + } + + return '0'; + } + + /** + * Maps simulation error information from the API response. + * + * @param simulation - The simulation result from the API. + * @returns The mapped error, or null if no error. + */ + #mapSimulationError( + simulation: SecurityAlertSimulationValidationResponse['simulation'], + ): TransactionScanError | null { + if (!simulation?.error && !simulation?.error_details) { + return null; + } + + return { + type: + simulation.error_details && 'type' in simulation.error_details + ? simulation.error_details.type + : null, + code: + simulation.error_details && 'code' in simulation.error_details + ? simulation.error_details.code + : null, + message: simulation.error ?? null, + }; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transaction-scan/index.ts b/merged-packages/tron-wallet-snap/src/services/transaction-scan/index.ts new file mode 100644 index 00000000..9fcc4ee3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transaction-scan/index.ts @@ -0,0 +1,2 @@ +export { TransactionScanService } from './TransactionScanService'; +export * from './types'; diff --git a/merged-packages/tron-wallet-snap/src/services/transaction-scan/types.ts b/merged-packages/tron-wallet-snap/src/services/transaction-scan/types.ts new file mode 100644 index 00000000..19588d6f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transaction-scan/types.ts @@ -0,0 +1,51 @@ +export type TransactionScanStatus = 'SUCCESS' | 'ERROR'; + +export type TransactionScanAssetChange = { + type: 'in' | 'out'; + value: string; + price: string | null; + symbol: string; + name: string; + logo: string | null; + assetType: string; +}; + +export type TransactionScanEstimatedChanges = { + assets: TransactionScanAssetChange[]; +}; + +export type TransactionScanValidation = { + type: 'Benign' | 'Warning' | 'Malicious' | 'Error' | null; + reason: string | null; +}; + +export type TransactionScanError = { + type: string | null; + code: string | null; + message: string | null; +}; + +export enum SimulationStatus { + Completed = 'COMPLETED', + Skipped = 'SKIPPED', + Failed = 'FAILED', +} + +export type TransactionScanResult = { + status: TransactionScanStatus; + estimatedChanges: TransactionScanEstimatedChanges; + validation: TransactionScanValidation; + error: TransactionScanError | null; + simulationStatus: SimulationStatus; +}; + +export enum SecurityAlertResponse { + Benign = 'Benign', + Warning = 'Warning', + Malicious = 'Malicious', +} + +export enum ScanStatus { + SUCCESS = 'SUCCESS', + ERROR = 'ERROR', +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts new file mode 100644 index 00000000..6d70e89d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.test.ts @@ -0,0 +1,1207 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/naming-convention */ +import { TransactionType, TransactionStatus } from '@metamask/keyring-api'; + +import type { TRC10TokenMetadata } from '../../clients/tron-http/types'; +import type { + TransactionInfo, + ContractTransactionInfo, +} from '../../clients/trongrid/types'; +import { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import swapTransactionInfoMock from './mocks/tron-http/gettransactioninfobyid/swap-transaction.json'; +import failedTransactionMock from './mocks/trongrid/account-transactions/failed-transaction.json'; +import nativeTransferMock from './mocks/trongrid/account-transactions/native-transfer.json'; +import swapAccountTransactionMock from './mocks/trongrid/account-transactions/swap-transaction.json'; +import trc10TransferMock from './mocks/trongrid/account-transactions/trc10-transfer.json'; +import trc20TransferMock from './mocks/trongrid/account-transactions/trc20-transfer.json'; +import contractInfoMock from './mocks/trongrid/account-trc20-transactions/contract-info.json'; +import swapContractInfoMock from './mocks/trongrid/account-trc20-transactions/swap-contract-info.json'; +import { TransactionMapper } from './TransactionsMapper'; + +describe('TransactionMapper', () => { + const mockAccount: TronKeyringAccount = { + id: 'test-account-id', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', // From native and TRC20 transfers + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], + entropySource: 'test-entropy', + derivationPath: 'm/0/0', + index: 0, + }; + + const swapTransactionMock = { + ...(swapAccountTransactionMock as unknown as TransactionInfo), + internal_transactions: + ( + swapTransactionInfoMock as { + internal_transactions?: TransactionInfo['internal_transactions']; + } + ).internal_transactions ?? [], + } as TransactionInfo; + + describe('mapTransaction', () => { + describe('TransferContract (Native TRX transfers)', () => { + it('should map a native TRX send transaction correctly', () => { + const rawTransaction = nativeTransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + }); + + const expectedTransaction = { + account: 'test-account-id', + type: TransactionType.Send, + id: '8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b', + from: [ + { + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + asset: { + amount: '0.01', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + asset: { + amount: '0.01', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + }, + ], + chain: 'tron:728126428', + status: TransactionStatus.Confirmed, + timestamp: 1756914747, + events: [ + { + status: TransactionStatus.Confirmed, + timestamp: 1756914747, + }, + ], + fees: [ + { + asset: { + amount: '0.266', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + ], + }; + + expect(result).toStrictEqual(expectedTransaction); + }); + }); + + describe('TransferAssetContract (TRC10 transfers)', () => { + // Use the actual address from the TRC10 transaction mock + const trc10Account: TronKeyringAccount = { + ...mockAccount, + id: 'test-trc10-account', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + }; + + it('maps TRC10 receive transaction with default 6 decimals when no metadata provided', () => { + const rawTransaction = trc10TransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: trc10Account, + trongridTransaction: rawTransaction, + }); + + const expectedTransaction = { + account: 'test-trc10-account', + type: TransactionType.Receive, + id: '24bc250718147fedde9ead0be7f17f50cfcfaf64d668ea834d5d1c69e5bf3bba', + from: [ + { + address: 'TEkrCfpcY8qGzRdSackqNWd5G5MUvAT1cX', + asset: { + // 88888888 / 10^6 = 88.888888 (default 6 decimals) + amount: '88.888888', + unit: 'UNKNOWN', + type: 'tron:728126428/trc10:1005119', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + asset: { + amount: '88.888888', + unit: 'UNKNOWN', + type: 'tron:728126428/trc10:1005119', + fungible: true, + }, + }, + ], + chain: 'tron:728126428', + status: TransactionStatus.Confirmed, + timestamp: 1769119311, + events: [ + { + status: TransactionStatus.Confirmed, + timestamp: 1769119311, + }, + ], + fees: [ + { + asset: { + amount: '282', + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + fungible: true, + }, + type: 'base', + }, + ], + }; + + expect(result).toStrictEqual(expectedTransaction); + }); + + it('maps TRC10 send transaction using metadata (decimals and symbol)', () => { + const rawTransaction = trc10TransferMock as TransactionInfo; + + const trc10TokenMetadata = new Map([ + [ + '1005119', + { + name: 'BestAdsCoin', + symbol: 'TRC20AdsCOM', + decimals: 3, + }, + ], + ]); + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: trc10Account, + trongridTransaction: rawTransaction, + trc10TokenMetadata, + }); + + // 88888888 / 10^3 = 88888.888 (using 3 decimals) + const fromAsset = result!.from[0]!.asset as { + amount: string; + unit: string; + }; + const toAsset = result!.to[0]!.asset as { + amount: string; + unit: string; + }; + expect(fromAsset.amount).toBe('88888.888'); + expect(fromAsset.unit).toBe('TRC20AdsCOM'); + expect(toAsset.amount).toBe('88888.888'); + expect(toAsset.unit).toBe('TRC20AdsCOM'); + }); + + it('maps TRC10 send transaction using 0 decimals from token metadata', () => { + const rawTransaction = trc10TransferMock as TransactionInfo; + + const trc10TokenMetadata = new Map([ + [ + '1005119', + { + name: 'WholeToken', + symbol: 'WHL', + decimals: 0, + }, + ], + ]); + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: trc10Account, + trongridTransaction: rawTransaction, + trc10TokenMetadata, + }); + + // 88888888 / 10^0 = 88888888 (using 0 decimals) + const fromAsset = result!.from[0]!.asset as { + amount: string; + unit: string; + }; + const toAsset = result!.to[0]!.asset as { + amount: string; + unit: string; + }; + expect(fromAsset.amount).toBe('88888888'); + expect(fromAsset.unit).toBe('WHL'); + expect(toAsset.amount).toBe('88888888'); + expect(toAsset.unit).toBe('WHL'); + }); + + it('handles decimal asset_name values when mapping TRC10 transactions', () => { + const rawTransaction = trc10TransferMock as TransactionInfo; + + const trc10TokenMetadata = new Map([ + [ + '1005119', + { + name: 'BestAdsCoin', + symbol: 'TRC20AdsCOM', + decimals: 6, + }, + ], + ]); + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: trc10Account, + trongridTransaction: rawTransaction, + trc10TokenMetadata, + }); + + expect(result).not.toBeNull(); + const fromAsset = result!.from[0]!.asset as { + type: string; + unit: string; + }; + expect(fromAsset.type).toBe('tron:728126428/trc10:1005119'); + expect(fromAsset.unit).toBe('TRC20AdsCOM'); + }); + }); + + describe('TriggerSmartContract (TRC20 transfers)', () => { + it('should map a TRC20 send transaction with assistance data correctly', () => { + const rawTransaction = trc20TransferMock as TransactionInfo; + const trc20Transfers = [ + contractInfoMock.data[0] as ContractTransactionInfo, + ]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + trc20Transfers, + }); + + const expectedTransaction = { + account: 'test-account-id', + type: TransactionType.Send, + id: '35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa', + from: [ + { + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + asset: { + amount: '0.01', + unit: 'USDT', + type: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + asset: { + amount: '0.01', + unit: 'USDT', + type: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + fungible: true, + }, + }, + ], + chain: 'tron:728126428', + status: TransactionStatus.Confirmed, + timestamp: 1757590707, + events: [ + { + status: TransactionStatus.Confirmed, + timestamp: 1757590707, + }, + ], + fees: [ + { + asset: { + amount: '12.9878', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + { + asset: { + amount: '345', + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + fungible: true, + }, + type: 'base', + }, + { + asset: { + amount: '407', + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + fungible: true, + }, + type: 'base', + }, + ], + }; + + expect(result).toStrictEqual(expectedTransaction); + }); + + it('should return null for TriggerSmartContract without assistance data and no call_value', () => { + const rawTransaction = trc20TransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + // No trc20Transfers provided and no call_value + }); + + expect(result).toBeNull(); + }); + + it('should map a TRC20 approval transaction as Unknown type', () => { + const rawTransaction = trc20TransferMock as TransactionInfo; + const approvalTransfer: ContractTransactionInfo = { + ...contractInfoMock.data[0], + type: 'Approval', // TRC20 approve operation + } as ContractTransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + trc20Transfers: [approvalTransfer], + }); + + expect(result).not.toBeNull(); + expect(result?.type).toBe(TransactionType.Unknown); + expect(result?.id).toBe(approvalTransfer.transaction_id); + }); + + it('should map a TRC20 transfer transaction as Send/Receive type', () => { + const rawTransaction = trc20TransferMock as TransactionInfo; + const transferTransfer: ContractTransactionInfo = { + ...contractInfoMock.data[0], + type: 'Transfer', // Explicit Transfer type + } as ContractTransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + trc20Transfers: [transferTransfer], + }); + + expect(result).not.toBeNull(); + // Since account is the sender, it should be mapped as Send + expect(result?.type).toBe(TransactionType.Send); + }); + }); + + describe('TRC20-only transactions', () => { + it('should map a TRC20-only approval as Unknown type', () => { + const approvalTrc20: ContractTransactionInfo = { + transaction_id: 'approval-tx-id-123', + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1757590707000, + from: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + to: 'TSomeSpenderAddress123456789012', + type: 'Approval', + value: '1000000000', + }; + + const result = TransactionMapper.mapTransactions({ + scope: Network.Mainnet, + account: mockAccount, + rawTransactions: [], + trc20Transactions: [approvalTrc20], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe(TransactionType.Unknown); + expect(result[0]?.id).toBe('approval-tx-id-123'); + }); + + it('should map a TRC20-only transfer as Send/Receive type', () => { + const transferTrc20: ContractTransactionInfo = { + transaction_id: 'transfer-tx-id-456', + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1757590707000, + from: 'TSomeOtherAddress123456789012', + to: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', // Account receives + type: 'Transfer', + value: '1000000', + }; + + const result = TransactionMapper.mapTransactions({ + scope: Network.Mainnet, + account: mockAccount, + rawTransactions: [], + trc20Transactions: [transferTrc20], + }); + + expect(result).toHaveLength(1); + // Account is the receiver + expect(result[0]?.type).toBe(TransactionType.Receive); + }); + }); + + describe('Fee calculation', () => { + it('should calculate TRX fees for native transfers', () => { + const rawTransaction = nativeTransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + }); + + const expectedFees = [ + { + asset: { + amount: '0.266', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + ]; + + expect(result?.fees).toStrictEqual(expectedFees); + }); + + it('should calculate comprehensive fees for TRC20 transfers', () => { + const rawTransaction = trc20TransferMock as TransactionInfo; + const trc20Transfers = [ + contractInfoMock.data[0] as ContractTransactionInfo, + ]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + trc20Transfers, + }); + + const expectedFees = [ + { + asset: { + amount: '12.9878', + unit: 'TRX', + type: 'tron:728126428/slip44:195', + fungible: true, + }, + type: 'base', + }, + { + asset: { + amount: '345', + unit: 'BANDWIDTH', + type: 'tron:728126428/slip44:bandwidth', + fungible: true, + }, + type: 'base', + }, + { + asset: { + amount: '407', + unit: 'ENERGY', + type: 'tron:728126428/slip44:energy', + fungible: true, + }, + type: 'base', + }, + ]; + + expect(result?.fees).toStrictEqual(expectedFees); + }); + }); + }); + + describe('Staking (Freeze/Unfreeze)', () => { + it('should map FreezeBalanceV2Contract (stake) as send with staked asset', () => { + const native = nativeTransferMock as TransactionInfo; + const ownerHex = (native.raw_data.contract?.[0] as any)?.parameter?.value + ?.owner_address; + + const freezeTx = { + ret: [{ contractRet: 'SUCCESS', fee: 200000 }], // 0.2 TRX + signature: [], + txID: 'freeze-stake-txid', + net_usage: 100, + raw_data_hex: '0x', + net_fee: 0, + energy_usage: 0, + blockNumber: 1, + block_timestamp: native.block_timestamp, + energy_fee: 0, + energy_usage_total: 0, + raw_data: { + contract: [ + { + type: 'FreezeBalanceV2Contract', + parameter: { + value: { + owner_address: ownerHex, + frozen_balance: 1_000_000, // 1 TRX + resource: 'BANDWIDTH', + }, + type_url: + 'type.googleapis.com/protocol.FreezeBalanceV2Contract', + }, + }, + ], + ref_block_bytes: '0x00', + ref_block_hash: '0x00', + expiration: Date.now() + 60_000, + timestamp: native.block_timestamp, + }, + internal_transactions: [], + } as unknown as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: freezeTx, + }); + + expect(result).toBeDefined(); + const tx = result!; + expect(tx.type).toBe(TransactionType.StakeDeposit); + expect(tx.account).toBe(mockAccount.id); + expect(tx.chain).toBe(Network.Mainnet); + // From native TRX, to staked asset + const fromAsset = tx.from[0]!.asset as { + unit: string; + type: string; + amount: string; + fungible: true; + }; + const toAsset = tx.to[0]!.asset as { + unit: string; + type: string; + amount: string; + fungible: true; + }; + expect(fromAsset.type).toBe('tron:728126428/slip44:195'); + expect(fromAsset.amount).toBe('1'); + expect(toAsset.type).toBe( + 'tron:728126428/slip44:195-staked-for-bandwidth', + ); + expect(toAsset.amount).toBe('1'); + }); + + it('should map UnfreezeBalanceV2Contract (unstake) as receive with staked asset', () => { + const native = nativeTransferMock as TransactionInfo; + const ownerHex = (native.raw_data.contract?.[0] as any)?.parameter?.value + ?.owner_address; + + const unfreezeTx = { + ret: [{ contractRet: 'SUCCESS', fee: 150000 }], // 0.15 TRX + signature: [], + txID: 'unfreeze-unstake-txid', + net_usage: 80, + raw_data_hex: '0x', + net_fee: 0, + energy_usage: 0, + blockNumber: 2, + block_timestamp: native.block_timestamp, + energy_fee: 0, + energy_usage_total: 0, + raw_data: { + contract: [ + { + type: 'UnfreezeBalanceV2Contract', + parameter: { + value: { + owner_address: ownerHex, + unfreeze_balance: 2_000_000, // 2 TRX + resource: 'ENERGY', + }, + type_url: + 'type.googleapis.com/protocol.UnfreezeBalanceV2Contract', + }, + }, + ], + ref_block_bytes: '0x00', + ref_block_hash: '0x00', + expiration: Date.now() + 60_000, + timestamp: native.block_timestamp, + }, + internal_transactions: [], + } as unknown as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: unfreezeTx, + }); + + expect(result).toBeDefined(); + const tx2 = result!; + expect(tx2.type).toBe(TransactionType.StakeWithdraw); + expect(tx2.account).toBe(mockAccount.id); + expect(tx2.chain).toBe(Network.Mainnet); + // From staked asset, to native TRX + const fromAsset2 = tx2.from[0]!.asset as { + unit: string; + type: string; + amount: string; + fungible: true; + }; + const toAsset2 = tx2.to[0]!.asset as { + unit: string; + type: string; + amount: string; + fungible: true; + }; + expect(fromAsset2.type).toBe( + 'tron:728126428/slip44:195-staked-for-energy', + ); + expect(fromAsset2.amount).toBe('2'); + expect(toAsset2.type).toBe('tron:728126428/slip44:195'); + expect(toAsset2.amount).toBe('2'); + }); + }); + describe('mapTransactions', () => { + it('should map multiple different transaction types correctly', () => { + const rawTransactions = [ + nativeTransferMock, + trc10TransferMock, + trc20TransferMock, + ] as TransactionInfo[]; + const trc20AssistanceData = + contractInfoMock.data as ContractTransactionInfo[]; + + const result = TransactionMapper.mapTransactions({ + scope: Network.Mainnet, + account: mockAccount, + rawTransactions, + trc20Transactions: trc20AssistanceData, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + // All transactions map successfully: + // 3 raw transactions (native TRX + TRC10 + TRC20 send) + // + 2 TRC20-only transactions (USDDOLD receive + USDT receive) + expect(result.filter((tx) => tx !== null)).toHaveLength(5); + }); + + it('should handle empty input arrays', () => { + const result = TransactionMapper.mapTransactions({ + scope: Network.Mainnet, + account: mockAccount, + rawTransactions: [], + trc20Transactions: [], + }); + + expect(result).toStrictEqual([]); + }); + }); + + describe('Edge cases', () => { + it('should handle transaction with missing contract data', () => { + const malformedTransaction = { + txID: 'test-tx-id', + raw_data: { + contract: undefined, + }, + } as any; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: malformedTransaction, + }); + + expect(result).toBeNull(); + }); + + it('should return null for unsupported contract types', () => { + const mockRawData = nativeTransferMock?.raw_data; + const rawTransaction = { + ...nativeTransferMock, + raw_data: { + ...mockRawData, + contract: [ + { + type: 'UnsupportedContract', + parameter: {}, + }, + ], + }, + } as any; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: rawTransaction, + }); + + expect(result).toBeNull(); + }); + }); + + describe('Network-specific behavior', () => { + it('should work with different networks (Shasta)', () => { + const rawTransaction = nativeTransferMock as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Shasta, // Use Shasta instead of Mainnet + account: mockAccount, + trongridTransaction: rawTransaction, + }); + + const expectedTransaction = { + account: 'test-account-id', + type: 'send', + id: '8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b', + from: [ + { + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + asset: { + amount: '0.01', + unit: 'TRX', + type: 'tron:2494104990/slip44:195', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB', + asset: { + amount: '0.01', + unit: 'TRX', + type: 'tron:2494104990/slip44:195', + fungible: true, + }, + }, + ], + chain: 'tron:2494104990', + status: TransactionStatus.Confirmed, + timestamp: 1756914747, + events: [ + { + status: TransactionStatus.Confirmed, + timestamp: 1756914747, + }, + ], + fees: [ + { + asset: { + amount: '0.266', + unit: 'TRX', + type: 'tron:2494104990/slip44:195', + fungible: true, + }, + type: 'base', + }, + ], + }; + + expect(result).toStrictEqual(expectedTransaction); + }); + }); + + describe('Failed Transactions', () => { + it('maps a failed TriggerSmartContract transaction (OUT_OF_ENERGY) without TRC20 data', () => { + const failedTx = failedTransactionMock as unknown as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: failedTx, + trc20Transfers: [], + }); + + expect(result).not.toBeNull(); + expect(result?.status).toStrictEqual(TransactionStatus.Failed); + expect(result?.type).toStrictEqual(TransactionType.Unknown); + expect(result?.from).toStrictEqual([]); + expect(result?.to).toStrictEqual([]); + expect(result?.fees).toBeDefined(); + }); + }); + + describe('Swap Transactions', () => { + it('maps a TRX → TRC20 swap transaction correctly', () => { + const swapTx = swapTransactionMock as unknown as TransactionInfo; + const trc20Transfers = + swapContractInfoMock.data as unknown as ContractTransactionInfo[]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: swapTx, + trc20Transfers, + }); + + expect(result).not.toBeNull(); + expect(result?.type).toStrictEqual(TransactionType.Swap); + expect(result?.status).toStrictEqual(TransactionStatus.Confirmed); + + // Check TRX → USDT swap + expect(result?.from[0]?.asset).toHaveProperty('unit', 'TRX'); + expect(result?.from[0]?.asset).toHaveProperty('amount', '19.9125'); + expect(result?.to[0]?.asset).toHaveProperty('unit', 'USDT'); + }); + + it('maps a TRC20 ↔ TRC20 swap transaction correctly', () => { + const mockTrc20Swap = { + ...swapTransactionMock, + } as unknown as TransactionInfo; + + const trc20Transfers: ContractTransactionInfo[] = [ + { + transaction_id: mockTrc20Swap.txID, + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1632825600000, + from: mockAccount.address, + to: 'TContractAddress', + type: 'Transfer', + value: '100000000', + }, + { + transaction_id: mockTrc20Swap.txID, + token_info: { + symbol: 'USDC', + address: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', + decimals: 6, + name: 'USD Coin', + }, + block_timestamp: 1632825600000, + from: 'TContractAddress', + to: mockAccount.address, + type: 'Transfer', + value: '99500000', + }, + ]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: mockTrc20Swap, + trc20Transfers, + }); + + expect(result).not.toBeNull(); + expect(result?.type).toStrictEqual(TransactionType.Swap); + expect(result?.from[0]?.asset).toHaveProperty('unit', 'USDT'); + expect(result?.from[0]?.asset).toHaveProperty('amount', '100'); + expect(result?.to[0]?.asset).toHaveProperty('unit', 'USDC'); + expect(result?.to[0]?.asset).toHaveProperty('amount', '99.5'); + }); + }); + + describe('Non-Swap Scenarios', () => { + it('does not detect a swap when only receiving TRC20 (no TRX movement)', () => { + const mockTx = { + ...swapTransactionMock, + internal_transactions: [], + } as unknown as TransactionInfo; + + const trc20Transfers: ContractTransactionInfo[] = [ + { + transaction_id: mockTx.txID, + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1632825600000, + from: 'TSomeOtherAddress', + to: mockAccount.address, + type: 'Transfer', + value: '50000000', + }, + ]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: mockTx, + trc20Transfers, + }); + + expect(result).not.toBeNull(); + expect(result?.type).toStrictEqual(TransactionType.Receive); + }); + + it('does not detect a swap when only sending TRC20', () => { + const mockTx = { + ...swapTransactionMock, + internal_transactions: [], + } as unknown as TransactionInfo; + + const trc20Transfers: ContractTransactionInfo[] = [ + { + transaction_id: mockTx.txID, + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1632825600000, + from: mockAccount.address, + to: 'TSomeOtherAddress', + type: 'Transfer', + value: '50000000', + }, + ]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: mockTx, + trc20Transfers, + }); + + expect(result).not.toBeNull(); + expect(result?.type).toStrictEqual(TransactionType.Send); + }); + + it('does not detect a swap when sending and receiving the same token (send to self)', () => { + const mockTx = { + ...swapTransactionMock, + } as unknown as TransactionInfo; + + const trc20Transfers: ContractTransactionInfo[] = [ + { + transaction_id: mockTx.txID, + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1632825600000, + from: mockAccount.address, + to: 'TContract', + type: 'Transfer', + value: '100000000', + }, + { + transaction_id: mockTx.txID, + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1632825600000, + from: 'TContract', + to: mockAccount.address, + type: 'Transfer', + value: '100000000', + }, + ]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: mockTx, + trc20Transfers, + }); + + expect(result).not.toBeNull(); + expect(result?.type).not.toStrictEqual(TransactionType.Swap); + }); + + it('does not detect TRX swap when there are no internal transactions', () => { + const mockTx = { + ...swapTransactionMock, + internal_transactions: [], + } as unknown as TransactionInfo; + + const trc20Transfers: ContractTransactionInfo[] = [ + { + transaction_id: mockTx.txID, + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1632825600000, + from: 'TContract', + to: mockAccount.address, + type: 'Transfer', + value: '50000000', + }, + ]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: mockTx, + trc20Transfers, + }); + + expect(result).not.toBeNull(); + expect(result?.type).toStrictEqual(TransactionType.Receive); + }); + + it('handles TRX movements with zero callValue correctly', () => { + const mockTx = { + ...swapTransactionMock, + internal_transactions: [ + { + from_address: mockAccount.address.toLowerCase(), + to_address: 'TContractAddress', + data: { + call_value: { _: 0 }, + note: '', + rejected: false, + }, + internal_tx_id: 'internal-0', + }, + ], + } as unknown as TransactionInfo; + + const trc20Transfers: ContractTransactionInfo[] = [ + { + transaction_id: mockTx.txID, + token_info: { + symbol: 'USDT', + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + decimals: 6, + name: 'Tether USD', + }, + block_timestamp: 1632825600000, + from: 'TContract', + to: mockAccount.address, + type: 'Transfer', + value: '50000000', + }, + ]; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: mockTx, + trc20Transfers, + }); + + expect(result).not.toBeNull(); + expect(result?.type).toStrictEqual(TransactionType.Receive); + }); + }); + + describe('TRC20-Only Transactions', () => { + it('maps TRC20-only transactions in mapTransactions', () => { + const rawTransactions: TransactionInfo[] = [ + nativeTransferMock, + trc10TransferMock, + trc20TransferMock, + ] as TransactionInfo[]; + + const trc20Transactions = [ + ...contractInfoMock.data, + { + transaction_id: 'trc20-only-tx-id', + token_info: { + symbol: 'AIRDROP', + address: 'TAirdropTokenAddress', + decimals: 18, + name: 'Airdrop Token', + }, + block_timestamp: 1632825600000, + from: 'TSomeContract', + to: mockAccount.address, + type: 'Transfer', + value: '1000000000000000000', + }, + ] as ContractTransactionInfo[]; + + const result = TransactionMapper.mapTransactions({ + scope: Network.Mainnet, + account: mockAccount, + rawTransactions, + trc20Transactions, + }); + + // 3 raw transactions (native TRX + TRC10 + TRC20 send) + // + 3 TRC20-only (USDDOLD receive + USDT receive + airdrop) + expect(result).toHaveLength(6); + const airdropTx = result.find((tx) => tx.id === 'trc20-only-tx-id'); + expect(airdropTx).toBeDefined(); + expect(airdropTx?.type).toStrictEqual(TransactionType.Receive); + expect(airdropTx?.from[0]?.asset).toHaveProperty('unit', 'AIRDROP'); + expect(airdropTx?.from[0]?.asset).toHaveProperty('amount', '1'); + }); + }); + + describe('TRX-Only Contract Interactions', () => { + it('maps a TriggerSmartContract with TRX call_value but no TRC20 data', () => { + const mockTrxOnlyContract = { + ...trc20TransferMock, + raw_data: { + ...trc20TransferMock.raw_data, + contract: [ + { + ...trc20TransferMock.raw_data.contract[0], + parameter: { + ...trc20TransferMock.raw_data.contract[0]?.parameter, + value: { + owner_address: '41bace09b0c75ff01da2cb86cf05bc0d6d1af21f5d', + contract_address: + '41a614f803b6fd780986a42c78ec9c7f77e6ded13c', + call_value: 50000000, // 50 TRX + data: '0x', + }, + }, + }, + ], + }, + } as unknown as TransactionInfo; + + const result = TransactionMapper.mapTransaction({ + scope: Network.Mainnet, + account: mockAccount, + trongridTransaction: mockTrxOnlyContract, + trc20Transfers: [], + }); + + expect(result).not.toBeNull(); + expect(result?.type).toStrictEqual(TransactionType.Send); + expect(result?.from[0]?.asset).toBeDefined(); + expect(result?.from[0]?.asset).toHaveProperty('unit', 'TRX'); + expect(result?.from[0]?.asset).toHaveProperty('amount', '50'); + expect(result?.to[0]?.asset).toBeDefined(); + expect(result?.to[0]?.asset).toHaveProperty('unit', 'TRX'); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts new file mode 100644 index 00000000..0845299e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsMapper.ts @@ -0,0 +1,1459 @@ +import type { CaipAssetType, Transaction } from '@metamask/keyring-api'; +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; +import { TronWeb } from 'tronweb'; + +import type { TRC10TokenMetadata } from '../../clients/tron-http/types'; +import type { + ContractTransactionInfo, + ContractValue, + GeneralContractInfo, + TransactionInfo, + TransferAssetContractInfo, + TransferContractInfo, +} from '../../clients/trongrid/types'; +import type { Network } from '../../constants'; +import { Networks } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { sunToTrx, toUiAmount } from '../../utils/conversion'; + +// TRC20 transaction types from TronGrid API +const TRC20_APPROVAL_TYPE = 'Approval'; + +export class TransactionMapper { + /** + * Creates a minimal pending transaction immediately after broadcast. + * This shows a placeholder transaction to the user while we wait for the + * background job to fetch full details from the blockchain. + * + * @param params - The parameters for creating the pending transaction + * @param params.txId - The transaction ID from the broadcast result + * @param params.account - The account that initiated the transaction + * @param params.scope - The network scope + * @returns A minimal pending transaction in keyring API format + */ + static createPendingTransaction({ + txId, + account, + scope, + }: { + txId: string; + account: TronKeyringAccount; + scope: Network; + }): Transaction { + const timestamp = Math.floor(Date.now() / 1000); + + return { + type: TransactionType.Unknown, + id: txId, + from: [ + { + address: account.address, + asset: { + unit: 'TRX', + type: `${scope}/slip44:195`, + amount: '0', + fungible: true, + }, + }, + ], + to: [ + { + address: account.address, + asset: { + unit: 'TRX', + type: `${scope}/slip44:195`, + amount: '0', + fungible: true, + }, + }, + ], + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp, + }, + ], + chain: scope, + status: TransactionStatus.Unconfirmed, + account: account.id, + timestamp, + fees: [], + }; + } + + /** + * Creates a detailed pending Send transaction immediately after broadcast. + * Uses the known transaction details to show a complete transaction to the user + * before blockchain confirmation. + * + * @param params - The parameters for creating the pending send transaction + * @param params.txId - The transaction ID from the broadcast result + * @param params.account - The account that initiated the transaction + * @param params.scope - The network scope + * @param params.toAddress - The recipient address + * @param params.amount - The amount being sent (in human-readable format) + * @param params.assetType - The CAIP asset type + * @param params.assetSymbol - The asset symbol (e.g., 'TRX', 'USDT') + * @returns A detailed pending Send transaction in keyring API format + */ + static createPendingSendTransaction({ + txId, + account, + scope, + toAddress, + amount, + assetType, + assetSymbol, + }: { + txId: string; + account: TronKeyringAccount; + scope: Network; + toAddress: string; + amount: string; + assetType: CaipAssetType; + assetSymbol: string; + }): Transaction { + const timestamp = Math.floor(Date.now() / 1000); + + return { + type: TransactionType.Send, + id: txId, + from: [ + { + address: account.address, + asset: { + unit: assetSymbol, + type: assetType, + amount, + fungible: true, + }, + }, + ], + to: [ + { + address: toAddress, + asset: { + unit: assetSymbol, + type: assetType, + amount, + fungible: true, + }, + }, + ], + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp, + }, + ], + chain: scope, + status: TransactionStatus.Unconfirmed, + account: account.id, + timestamp, + fees: [], + }; + } + + /** + * Calculate fees for Tron transactions including Energy and Bandwidth as separate assets. + * + * @param network The network configuration. + * @param transactionInfo The raw transaction info. + * @returns Array of fee objects. + */ + static #calculateTronFees( + network: Network, + transactionInfo: TransactionInfo, + ): Transaction['fees'] { + const fees: Transaction['fees'] = []; + + const { + nativeToken: tronAsset, + bandwidth: bandwidthAsset, + energy: energyAsset, + } = Networks[network]; + + // Base TRX fee calculation + const transactionFeeInSun = transactionInfo.ret.reduce( + (total, result) => total + (result.fee || 0), + 0, + ); + const transactionFee = sunToTrx(transactionFeeInSun).toNumber(); + + const setFeeIfPresent = ( + amount: number, + asset: { id: CaipAssetType; symbol: string }, + ): void => { + if (amount > 0) { + fees.push({ + type: 'base', + asset: { + type: asset.id, + unit: asset.symbol, + amount: amount.toString(), + fungible: true, + }, + }); + } + }; + + setFeeIfPresent(transactionFee, tronAsset); + setFeeIfPresent(transactionInfo.net_usage, bandwidthAsset); + setFeeIfPresent(transactionInfo.energy_usage, energyAsset); + + return fees; + } + + /** + * Computes the transaction status based on blockNumber and contractRet. + * + * @param trongridTransaction - The raw transaction data from Trongrid. + * @returns The transaction status (pending/confirmed/failed). + */ + static #computeTransactionStatus( + trongridTransaction: TransactionInfo, + ): TransactionStatus { + const isPending = !trongridTransaction.blockNumber; + const contractRet = trongridTransaction.ret?.[0]?.contractRet; + const isFailed = contractRet && contractRet !== 'SUCCESS'; + + if (isPending) { + return TransactionStatus.Unconfirmed; + } + if (isFailed) { + return TransactionStatus.Failed; + } + + return TransactionStatus.Confirmed; + } + + /** + * Computes the transaction type based on the account and transaction participants. + * + * @param params - The parameters for type computation. + * @param params.accountAddress - The account address. + * @param params.from - The sender address. + * @param params.to - The receiver address. + * @param params.trc20Type - Optional TRC20 transaction type from TronGrid API. + * @returns The transaction type (send/receive/swap/unknown). + */ + static #computeTransactionType({ + accountAddress, + from, + to, + trc20Type, + }: { + accountAddress: string; + from: string; + to: string; + trc20Type?: string; + }): TransactionType { + if (trc20Type === TRC20_APPROVAL_TYPE) { + return TransactionType.Unknown; + } + if (from === accountAddress && to === accountAddress) { + return TransactionType.Swap; // This is a self-transfer, but in the context of a DEX, it's a swap + } + if (from === accountAddress) { + return TransactionType.Send; + } + if (to === accountAddress) { + return TransactionType.Receive; + } + return TransactionType.Unknown; + } + + /** + * Converts a hex address to a Tron base58 address. + * + * @param hexAddress - The hex address to convert. + * @returns The Tron base58 address. + */ + static #toTronAddress(hexAddress: string): string { + return TronWeb.address.fromHex(hexAddress); + } + + /** + * Checks if there's TRX movement in the transaction's internal_transactions. + * + * @param trongridTransaction - The transaction to check. + * @param accountAddress - The account address to check for. + * @returns True if TRX movement is detected, false otherwise. + */ + static #hasTrxMovementInTransaction( + trongridTransaction: TransactionInfo, + accountAddress: string, + ): boolean { + const internalTransactions = trongridTransaction.internal_transactions; + if (!internalTransactions || internalTransactions.length === 0) { + return false; + } + + // Convert account address to hex for comparison + const accountHex = TronWeb.address.toHex(accountAddress).toLowerCase(); + + // Check for TRX movements where this account is involved + // Note: internal_transactions from Full Node API uses caller_address/transferTo_address + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return internalTransactions.some((internal: any) => { + const fromHex = internal.caller_address?.toLowerCase(); + const toHex = internal.transferTo_address?.toLowerCase(); + const hasCallValue = internal.callValueInfo?.some( + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (valueInfo: any) => (valueInfo.callValue ?? 0) > 0, + ); + + return hasCallValue && (fromHex === accountHex || toHex === accountHex); + }); + } + + /** + * Maps a TransferContract (native TRX transfer) transaction. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.trongridTransaction - The raw transaction data from Trongrid. + * @returns The mapped Transaction or null if the transaction is not supported. + */ + static #mapTransferContract({ + scope, + account, + trongridTransaction, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + }): Transaction | null { + const firstContract = trongridTransaction.raw_data + .contract[0] as TransferContractInfo; + const contractValue = firstContract.parameter.value; + + const from = this.#toTronAddress(contractValue.owner_address); + const to = this.#toTronAddress(contractValue.to_address); + + // Determine transaction status using helper + const status = this.#computeTransactionStatus(trongridTransaction); + + // Use transaction timestamp for confirmed, current time for pending + const isPending = status === TransactionStatus.Unconfirmed; + const timestamp = isPending + ? Math.floor(Date.now() / 1000) + : Math.floor(trongridTransaction.block_timestamp / 1000); + + // Convert from sun to TRX + const amountInSun = contractValue.amount; + const amountInTrx = sunToTrx(amountInSun).toString(); + + // Calculate comprehensive fees including Energy and Bandwidth + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + + // Determine transaction type using helper + const type = this.#computeTransactionType({ + accountAddress: account.address, + from, + to, + }); + + const tronAsset = Networks[scope].nativeToken; + + return { + type, + id: trongridTransaction.txID, + from: [ + { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + address: from as any, + asset: { + unit: tronAsset.symbol, + type: tronAsset.id, + amount: amountInTrx, + fungible: true, + }, + }, + ], + to: [ + { + address: to, + asset: { + unit: tronAsset.symbol, + type: tronAsset.id, + amount: amountInTrx, + fungible: true, + }, + }, + ], + events: [ + { + status, + timestamp, + }, + ], + chain: scope, + status, + account: account.id, + timestamp, + fees, + }; + } + + /** + * Maps a TransferAssetContract (TRC10 token transfer) transaction. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.trongridTransaction - The raw transaction data from Trongrid. + * @param params.trc10TokenMetadata - Optional map of TRC10 token ID to metadata (including decimals). + * @returns The mapped Transaction. + */ + static #mapTransferAssetContract({ + scope, + account, + trongridTransaction, + trc10TokenMetadata, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + trc10TokenMetadata?: Map; + }): Transaction { + const firstContract = trongridTransaction.raw_data + .contract[0] as TransferAssetContractInfo; + const contractValue = firstContract.parameter.value; + + const from = this.#toTronAddress(contractValue.owner_address); + const to = this.#toTronAddress(contractValue.to_address); + + // Determine transaction status using helper + const status = this.#computeTransactionStatus(trongridTransaction); + + // Use transaction timestamp for confirmed, current time for pending + const isPending = status === TransactionStatus.Unconfirmed; + const timestamp = isPending + ? Math.floor(Date.now() / 1000) + : Math.floor(trongridTransaction.block_timestamp / 1000); + + const assetName = contractValue.asset_name; + + const tokenMetadata = trc10TokenMetadata?.get(assetName); + const decimals = tokenMetadata?.decimals ?? 6; + const symbol = tokenMetadata?.symbol ?? 'UNKNOWN'; + + // Convert from smallest unit to human-readable amount using actual token decimals + const amountInSmallestUnit = contractValue.amount; + const amountInReadableUnit = toUiAmount( + amountInSmallestUnit, + decimals, + ).toFixed(); + + // Calculate comprehensive fees including Energy and Bandwidth + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + + // Determine transaction type using helper + const type = this.#computeTransactionType({ + accountAddress: account.address, + from, + to, + }); + + return { + type, + id: trongridTransaction.txID, + from: [ + { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + address: from as any, + asset: { + unit: symbol, + type: `${scope}/trc10:${assetName}`, + amount: amountInReadableUnit, + fungible: true, + }, + }, + ], + to: [ + { + address: to, + asset: { + unit: symbol, + type: `${scope}/trc10:${assetName}`, + amount: amountInReadableUnit, + fungible: true, + }, + }, + ], + events: [ + { + status, + timestamp, + }, + ], + chain: scope, + status, + account: account.id, + timestamp, + fees, + }; + } + + /** + * Maps a TRC20-only transaction (transactions where user received tokens but wasn't the initiator). + * Examples: airdrops, contract withdrawals, etc. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope. + * @param params.account - The account involved. + * @param params.trc20Transfer - The TRC20 transfer. + * @returns The mapped Transaction or null if not supported. + */ + static #mapTrc20OnlyTransaction({ + scope, + account, + trc20Transfer, + }: { + scope: Network; + account: TronKeyringAccount; + trc20Transfer: ContractTransactionInfo; + }): Transaction | null { + const { + from, + to, + value, + token_info: tokenInfo, + block_timestamp: blockTimestamp, + transaction_id: transactionId, + } = trc20Transfer; + + // Calculate amount in human-readable format + const amount = toUiAmount(value, tokenInfo.decimals).toFixed(); + + // Determine transaction type + const type = this.#computeTransactionType({ + accountAddress: account.address, + from, + to, + trc20Type: trc20Transfer.type, + }); + + // TRC20-only transactions are always confirmed (they have a block timestamp) + const status = TransactionStatus.Confirmed; + + // TRC20-only transactions typically don't incur fees for the recipient + const fees: Transaction['fees'] = []; + + return { + type, + id: transactionId, + from: [ + { + address: from, + asset: { + unit: tokenInfo.symbol, + type: `${scope}/trc20:${tokenInfo.address}`, + amount, + fungible: true, + }, + }, + ], + to: [ + { + address: to, + asset: { + unit: tokenInfo.symbol, + type: `${scope}/trc20:${tokenInfo.address}`, + amount, + fungible: true, + }, + }, + ], + events: [ + { + status, + timestamp: Math.floor(blockTimestamp / 1000), + }, + ], + chain: scope, + status, + account: account.id, + timestamp: Math.floor(blockTimestamp / 1000), + fees, + }; + } + + /** + * Maps a TRC20 ↔ TRC20 swap transaction. + * + * @param params - The parameters for mapping the swap. + * @param params.scope - The network scope. + * @param params.account - The account involved in the swap. + * @param params.trongridTransaction - The raw transaction data. + * @param params.sentTransfer - The TRC20 transfer that was sent. + * @param params.receivedTransfer - The TRC20 transfer that was received. + * @returns The mapped swap Transaction. + */ + static #mapSwapTransaction({ + scope, + account, + trongridTransaction, + sentTransfer, + receivedTransfer, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + sentTransfer: ContractTransactionInfo; + receivedTransfer: ContractTransactionInfo; + }): Transaction { + const status = this.#computeTransactionStatus(trongridTransaction); + const isPending = status === TransactionStatus.Unconfirmed; + const timestamp = isPending + ? Math.floor(Date.now() / 1000) + : Math.floor(trongridTransaction.block_timestamp / 1000); + + // Calculate sent amount + const sentAmount = toUiAmount( + sentTransfer.value, + sentTransfer.token_info.decimals, + ).toFixed(); + + // Calculate received amount + const receivedAmount = toUiAmount( + receivedTransfer.value, + receivedTransfer.token_info.decimals, + ).toFixed(); + + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + + return { + type: TransactionType.Swap, + id: trongridTransaction.txID, + from: [ + { + address: sentTransfer.from, + asset: { + unit: sentTransfer.token_info.symbol, + type: `${scope}/trc20:${sentTransfer.token_info.address}`, + amount: sentAmount, + fungible: true, + }, + }, + ], + to: [ + { + address: receivedTransfer.to, + asset: { + unit: receivedTransfer.token_info.symbol, + type: `${scope}/trc20:${receivedTransfer.token_info.address}`, + amount: receivedAmount, + fungible: true, + }, + }, + ], + events: [ + { + status, + timestamp, + }, + ], + chain: scope, + status, + account: account.id, + timestamp, + fees, + }; + } + + /** + * Maps a TRX ↔ TRC20 swap transaction. + * + * @param params - The parameters for mapping the swap. + * @param params.scope - The network scope. + * @param params.account - The account involved in the swap. + * @param params.trongridTransaction - The raw transaction data. + * @param params.trc20Transfer - The TRC20 transfer. + * @returns The mapped swap Transaction. + */ + static #mapTrxToTrc20Swap({ + scope, + account, + trongridTransaction, + trc20Transfer, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + trc20Transfer: ContractTransactionInfo; + }): Transaction { + const status = this.#computeTransactionStatus(trongridTransaction); + const isPending = status === TransactionStatus.Unconfirmed; + const timestamp = isPending + ? Math.floor(Date.now() / 1000) + : Math.floor(trongridTransaction.block_timestamp / 1000); + + // Calculate TRC20 received amount + const trc20Amount = toUiAmount( + trc20Transfer.value, + trc20Transfer.token_info.decimals, + ).toFixed(); + + // Extract TRX amount - priority order: + // 1. call_value from main contract (user sent TRX directly) + // 2. internal_transactions where user is sender + // 3. Sum of all internal TRX (fallback for complex DEX swaps) + const contractInfo = trongridTransaction.raw_data.contract?.[0]; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contractCallValue = (contractInfo?.parameter?.value as any) + ?.call_value; + + let trxAmount = '0'; + + // First, check if user sent TRX directly via call_value + if (contractCallValue && contractCallValue > 0) { + trxAmount = sunToTrx(contractCallValue).toString(); + } else { + // Otherwise, look in internal_transactions + const internalTransactions = + trongridTransaction.internal_transactions ?? []; + const accountHex = TronWeb.address.toHex(account.address).toLowerCase(); + + // Find the TRX movement where the account is the sender + // Note: internal_transactions from Full Node API uses caller_address/callValueInfo + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const internal of internalTransactions as any[]) { + const fromHex = internal.caller_address?.toLowerCase(); + if (fromHex === accountHex) { + const callValue = + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + internal.callValueInfo?.find((vi: any) => vi.callValue) + ?.callValue ?? 0; + if (callValue > 0) { + trxAmount = sunToTrx(callValue).toString(); + break; + } + } + } + + // If no TRX found in internal_transactions, sum all internal TRX movements + // (DEX swaps often have TRX moving internally without the user's address) + if (trxAmount === '0' && internalTransactions.length > 0) { + let totalInternalTrx = 0; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const internal of internalTransactions as any[]) { + const callValue = + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + internal.callValueInfo?.find((vi: any) => vi.callValue) + ?.callValue ?? 0; + totalInternalTrx += callValue; + } + if (totalInternalTrx > 0) { + trxAmount = sunToTrx(totalInternalTrx).toString(); + } + } + } + + const tronAsset = Networks[scope].nativeToken; + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + + return { + type: TransactionType.Swap, + id: trongridTransaction.txID, + from: [ + { + address: account.address, + asset: { + unit: tronAsset.symbol, + type: tronAsset.id, + amount: trxAmount, + fungible: true, + }, + }, + ], + to: [ + { + address: account.address, + asset: { + unit: trc20Transfer.token_info.symbol, + type: `${scope}/trc20:${trc20Transfer.token_info.address}`, + amount: trc20Amount, + fungible: true, + }, + }, + ], + events: [ + { + status, + timestamp, + }, + ], + chain: scope, + status, + account: account.id, + timestamp, + fees, + }; + } + + /** + * Maps a TriggerSmartContract transaction, which can be a TRC20 transfer, swap, or TRX-only contract call. + * Uses TRC20 assistance data when available for enhanced parsing. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.trongridTransaction - The raw transaction data from Trongrid. + * @param params.trc20Transfers - Optional array of TRC20 transfers for this transaction ID. + * @returns The mapped Transaction or null if the transaction is not supported. + */ + static #mapTriggerSmartContract({ + scope, + account, + trongridTransaction, + trc20Transfers = [], + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + trc20Transfers?: ContractTransactionInfo[]; + }): Transaction | null { + // Determine transaction status first + const status = this.#computeTransactionStatus(trongridTransaction); + + const contractInfo = trongridTransaction.raw_data.contract?.[0]; + const ownerAddress = contractInfo?.parameter?.value?.owner_address; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contractAddress = (contractInfo?.parameter?.value as any) + ?.contract_address; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const callValue = (contractInfo?.parameter?.value as any)?.call_value; + + // Handle failed transactions (even without TRC20 data) + if (status === TransactionStatus.Failed) { + if (!ownerAddress) { + return null; + } + + const timestamp = Math.floor(trongridTransaction.block_timestamp / 1000); + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + + return { + type: TransactionType.Unknown, + id: trongridTransaction.txID, + from: [], + to: [], + events: [ + { + status, + timestamp, + }, + ], + chain: scope, + status, + account: account.id, + timestamp, + fees, + }; + } + + // Handle TRX-only smart contract calls (e.g., deposits, registrations) + // If there's a callValue (TRX sent) and no TRC20 transfers, map as a TRX send + if (trc20Transfers.length === 0 && callValue && callValue > 0) { + if (!ownerAddress || !contractAddress) { + return null; + } + + const timestamp = Math.floor(trongridTransaction.block_timestamp / 1000); + const trxAmount = sunToTrx(callValue).toString(); + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + const tronAsset = Networks[scope].nativeToken; + + return { + type: TransactionType.Send, + id: trongridTransaction.txID, + from: [ + { + address: this.#toTronAddress(ownerAddress), + asset: { + unit: tronAsset.symbol, + type: tronAsset.id, + amount: trxAmount, + fungible: true, + }, + }, + ], + to: [ + { + address: this.#toTronAddress(contractAddress), + asset: { + unit: tronAsset.symbol, + type: tronAsset.id, + amount: trxAmount, + fungible: true, + }, + }, + ], + events: [ + { + status, + timestamp, + }, + ], + chain: scope, + status, + account: account.id, + timestamp, + fees, + }; + } + + // If no TRC20 assistance data is available and no callValue, we can't parse this smart contract interaction, so skip it + if (trc20Transfers.length === 0 && (!callValue || callValue === 0)) { + return null; + } + + // At this point, we have TRC20 transfers to work with (or a combination with TRX) + // Check if this is a swap: + // 1. TRC20 ↔ TRC20: account is both sender and receiver with different tokens + // 2. TRX ↔ TRC20: account receives/sends TRC20 + TRX moves in opposite direction + + const sentTrc20Transfer = trc20Transfers.find( + (transfer) => transfer.from === account.address, + ); + const receivedTrc20Transfer = trc20Transfers.find( + (transfer) => transfer.to === account.address, + ); + + // Check for TRC20 ↔ TRC20 swap (different tokens) + const isTrc20ToTrc20Swap = + sentTrc20Transfer && + receivedTrc20Transfer && + sentTrc20Transfer.token_info.address !== + receivedTrc20Transfer.token_info.address; + + if ( + sentTrc20Transfer && + receivedTrc20Transfer && + trc20Transfers.length >= 2 && + isTrc20ToTrc20Swap + ) { + return this.#mapSwapTransaction({ + scope, + account, + trongridTransaction, + sentTransfer: sentTrc20Transfer, + receivedTransfer: receivedTrc20Transfer, + }); + } + + // Check for TRX ↔ TRC20 swap + // This happens when there's a TRC20 transfer and TRX movement in internal_transactions + // Note: TronGrid's /v1/accounts/{address}/transactions endpoint often returns + // internal_transactions as empty array. Full transaction details with internal_transactions + // are fetched by TransactionsService.#enrichPotentialSwaps() if needed. + const hasTrxMovement = this.#hasTrxMovementInTransaction( + trongridTransaction, + account.address, + ); + + // Also check if there's a call_value (TRX sent directly to contract) + const hasCallValue = callValue && callValue > 0; + + // Check if there are ANY internal_transactions with TRX movement (indicates DEX swap) + const hasAnyInternalTrxMovement = + trongridTransaction.internal_transactions && + trongridTransaction.internal_transactions.length > 0 && + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + trongridTransaction.internal_transactions.some((internal: any) => + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + internal.callValueInfo?.some((vi: any) => (vi.callValue ?? 0) > 0), + ); + + // TRX → TRC20: User receives TRC20 and there's TRX being sent or internal TRX movements + if ( + receivedTrc20Transfer && + !sentTrc20Transfer && + (hasTrxMovement || hasCallValue || hasAnyInternalTrxMovement) + ) { + return this.#mapTrxToTrc20Swap({ + scope, + account, + trongridTransaction, + trc20Transfer: receivedTrc20Transfer, + }); + } + + // TRC20 → TRX: User sends TRC20 and there's TRX being received + // (Less common, we'll implement this later if needed) + + // Otherwise, handle as a regular TRC20 transfer + // Use the first transfer (typically there's only one for regular transfers) + const trc20AssistanceData = trc20Transfers[0]; + + if (!trc20AssistanceData) { + return null; + } + + const { from, to } = trc20AssistanceData; + + // Convert from smallest unit to human-readable amount using token decimals + const valueInSmallestUnit = trc20AssistanceData.value; + const { decimals, address, symbol } = trc20AssistanceData.token_info; + + // Status already computed at the top of the method + const isPending = status === TransactionStatus.Unconfirmed; + + // Use transaction timestamp for confirmed, current time for pending + const timestamp = isPending + ? Math.floor(Date.now() / 1000) + : Math.floor(trc20AssistanceData.block_timestamp / 1000); + + // Convert from smallest unit to human-readable amount + const valueInReadableUnit = toUiAmount( + valueInSmallestUnit, + decimals, + ).toFixed(); + + // Determine transaction type + const type = this.#computeTransactionType({ + accountAddress: account.address, + from, + to, + trc20Type: trc20AssistanceData.type, + }); + + // Calculate comprehensive fees including Energy and Bandwidth from raw transaction data + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + + return { + type, + id: trc20AssistanceData.transaction_id, + from: [ + { + address: from, + asset: { + unit: symbol, + type: `${scope}/trc20:${address}`, + amount: valueInReadableUnit, + fungible: true, + }, + }, + ], + to: [ + { + address: to, + asset: { + unit: symbol, + type: `${scope}/trc20:${address}`, + amount: valueInReadableUnit, + fungible: true, + }, + }, + ], + events: [ + { + status, + timestamp, + }, + ], + chain: scope, + status, + account: account.id, + timestamp, + fees, + }; + } + + /** + * Maps a FreezeBalance(V2) staking transaction to a Transaction. + * Treat as a "send" of TRX into a staked resource asset (bandwidth/energy). + * + * @param params - The options object. + * @param params.scope - The network scope. + * @param params.account - The account executing the stake. + * @param params.trongridTransaction - The raw transaction to map. + * @returns The mapped transaction or null if unsupported. + */ + static #mapFreezeBalanceContract({ + scope, + account, + trongridTransaction, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + }): Transaction | null { + const firstContract = trongridTransaction.raw_data + .contract[0] as GeneralContractInfo; + const contractValue = firstContract?.parameter?.value as ContractValue & { + resource?: 'BANDWIDTH' | 'ENERGY'; + }; + if (!contractValue) { + return null; + } + + const ownerAddress: string | undefined = contractValue.owner_address; + if (!ownerAddress) { + return null; + } + const from = TronWeb.address.fromHex(ownerAddress); + const timestamp = Math.floor(trongridTransaction.block_timestamp / 1000); + + // V2 uses "frozen_balance"; legacy may use "frozen_balance" or "frozen_balance_v2" + const amountInSun: number = + Number( + contractValue.frozen_balance ?? + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (contractValue as any).frozen_balance_v2, + ) || 0; + const amountInTrx = sunToTrx(amountInSun).toString(); + + // Determine resource and corresponding staked asset metadata + const { resource } = contractValue as { resource?: 'BANDWIDTH' | 'ENERGY' }; + const isEnergy = resource === 'ENERGY'; + const isBandwidth = resource === 'BANDWIDTH'; + if (!isEnergy && !isBandwidth) { + // If we cannot determine the resource, skip mapping to avoid incorrect classification + return null; + } + + const tronAsset = Networks[scope].nativeToken; + const stakedAsset = isEnergy + ? Networks[scope].stakedForEnergy + : Networks[scope].stakedForBandwidth; + + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + + return { + type: TransactionType.StakeDeposit, + id: trongridTransaction.txID, + from: [ + { + address: from, + asset: { + unit: tronAsset.symbol, + type: tronAsset.id, + amount: amountInTrx, + fungible: true, + }, + }, + ], + to: [ + { + address: from, + asset: { + unit: stakedAsset.symbol, + type: stakedAsset.id as CaipAssetType, + amount: amountInTrx, + fungible: true, + }, + }, + ], + events: [ + { + status: TransactionStatus.Confirmed, + timestamp, + }, + ], + chain: scope, + status: TransactionStatus.Confirmed, + account: account.id, + timestamp, + fees, + }; + } + + /** + * Maps an UnfreezeBalance(V2) unstaking transaction to a Transaction. + * Treat as a "receive" of TRX from a staked resource asset (bandwidth/energy). + * + * @param params - The options object. + * @param params.scope - The network scope. + * @param params.account - The account executing the unstake. + * @param params.trongridTransaction - The raw transaction to map. + * @returns The mapped transaction or null if unsupported. + */ + static #mapUnfreezeBalanceContract({ + scope, + account, + trongridTransaction, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + }): Transaction | null { + const firstContract = trongridTransaction.raw_data + .contract[0] as GeneralContractInfo; + const contractValue = firstContract?.parameter?.value as ContractValue & { + resource?: 'BANDWIDTH' | 'ENERGY'; + }; + if (!contractValue) { + return null; + } + + const ownerAddress: string | undefined = contractValue.owner_address; + if (!ownerAddress) { + return null; + } + const to = TronWeb.address.fromHex(ownerAddress); + const timestamp = Math.floor(trongridTransaction.block_timestamp / 1000); + + // V2 uses "unfreeze_balance"; legacy may use this or a similar field + const amountInSun: number = Number(contractValue.unfreeze_balance) || 0; + const amountInTrx = sunToTrx(amountInSun).toString(); + + // Determine resource and corresponding staked asset metadata + const { resource } = contractValue as { resource?: 'BANDWIDTH' | 'ENERGY' }; + const isEnergy = resource === 'ENERGY'; + const isBandwidth = resource === 'BANDWIDTH'; + if (!isEnergy && !isBandwidth) { + return null; + } + + const tronAsset = Networks[scope].nativeToken; + const stakedAsset = isEnergy + ? Networks[scope].stakedForEnergy + : Networks[scope].stakedForBandwidth; + + const fees = TransactionMapper.#calculateTronFees( + scope, + trongridTransaction, + ); + + return { + type: TransactionType.StakeWithdraw, + id: trongridTransaction.txID, + from: [ + { + address: to, + asset: { + unit: stakedAsset.symbol, + type: stakedAsset.id as CaipAssetType, + amount: amountInTrx, + fungible: true, + }, + }, + ], + to: [ + { + address: to, + asset: { + unit: tronAsset.symbol, + type: tronAsset.id, + amount: amountInTrx, + fungible: true, + }, + }, + ], + events: [ + { + status: TransactionStatus.Confirmed, + timestamp, + }, + ], + chain: scope, + status: TransactionStatus.Confirmed, + account: account.id, + timestamp, + fees, + }; + } + + /** + * Maps a raw transaction using the appropriate mapping method based on contract type. + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.trongridTransaction - The raw transaction data from Trongrid. + * @param params.trc20Transfers - Optional array of TRC20 transfers for this transaction ID. + * @param params.trc10TokenMetadata - Optional map of TRC10 token ID to metadata (including decimals). + * @returns The mapped Transaction or null if the transaction is not supported. + */ + static mapTransaction({ + scope, + account, + trongridTransaction, + trc20Transfers = [], + trc10TokenMetadata, + }: { + scope: Network; + account: TronKeyringAccount; + trongridTransaction: TransactionInfo; + trc20Transfers?: ContractTransactionInfo[]; + trc10TokenMetadata?: Map; + }): Transaction | null { + /** + * Cheat Sheet of "raw_data" > "contract" > "type" + * + * TransferContract - Native TRX transfer + * TransferAssetContract - TRC10 token transfer + * TriggerSmartContract - Smart contract interaction (can be TRC20 transfer, swap, or TRX-only call) + */ + + // ASSUME: Only use the first contract to classify the transaction type. + + const firstContract = trongridTransaction.raw_data.contract?.[0]; + const contractType = firstContract?.type; + + if (!firstContract || !contractType) { + return null; + } + + switch (contractType) { + case 'TransferContract': + return this.#mapTransferContract({ + scope, + account, + trongridTransaction, + }); + + case 'TransferAssetContract': + return this.#mapTransferAssetContract({ + scope, + account, + trongridTransaction, + trc10TokenMetadata, + }); + + case 'FreezeBalanceV2Contract': + case 'FreezeBalanceContract': + return this.#mapFreezeBalanceContract({ + scope, + account, + trongridTransaction, + }); + + case 'UnfreezeBalanceV2Contract': + case 'UnfreezeBalanceContract': + return this.#mapUnfreezeBalanceContract({ + scope, + account, + trongridTransaction, + }); + + case 'TriggerSmartContract': + return this.#mapTriggerSmartContract({ + scope, + account, + trongridTransaction, + trc20Transfers, + }); + + default: + // Unsupported transaction type + return null; + } + } + + /** + * Maps raw transaction data with TRC20 assistance data to create a complete transaction mapping. + * This method treats raw transactions as the primary source and uses TRC20 data as assistance. + * It also handles TRC20-only transactions (e.g., airdrops, contract withdrawals). + * + * @param params - The parameters for mapping the transaction. + * @param params.scope - The network scope (e.g., mainnet, shasta). + * @param params.account - The TronKeyringAccount for which the transaction is being mapped. + * @param params.rawTransactions - Array of raw transaction data (primary source). + * @param params.trc20Transactions - Array of TRC20 transaction data (assistance). + * @param params.trc10TokenMetadata - Optional map of TRC10 token ID to metadata (including decimals). + * @returns Array of mapped Transactions. + */ + static mapTransactions({ + scope, + account, + rawTransactions, + trc20Transactions, + trc10TokenMetadata, + }: { + scope: Network; + account: TronKeyringAccount; + rawTransactions: TransactionInfo[]; + trc20Transactions: ContractTransactionInfo[]; + trc10TokenMetadata?: Map; + }): Transaction[] { + const transactions: Transaction[] = []; + const processedTxIds = new Set(); + + // Group TRC20 transactions by transaction_id + const trc20ByTxId = new Map(); + for (const trc20Tx of trc20Transactions) { + const existing = trc20ByTxId.get(trc20Tx.transaction_id) ?? []; + existing.push(trc20Tx); + trc20ByTxId.set(trc20Tx.transaction_id, existing); + } + + // Process each raw transaction as the primary source + for (const rawTx of rawTransactions) { + const trc20Transfers = trc20ByTxId.get(rawTx.txID) ?? []; + + const mappedTx = this.mapTransaction({ + scope, + account, + trongridTransaction: rawTx, + trc20Transfers, + trc10TokenMetadata, + }); + + if (mappedTx) { + transactions.push(mappedTx); + processedTxIds.add(rawTx.txID); + } + } + + // Process TRC20-only transactions (those not covered by raw transactions) + for (const trc20Tx of trc20Transactions) { + if (!processedTxIds.has(trc20Tx.transaction_id)) { + const mappedTx = this.#mapTrc20OnlyTransaction({ + scope, + account, + trc20Transfer: trc20Tx, + }); + + if (mappedTx) { + transactions.push(mappedTx); + processedTxIds.add(trc20Tx.transaction_id); + } + } + } + + return transactions; + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.test.ts new file mode 100644 index 00000000..62f5094c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.test.ts @@ -0,0 +1,145 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; + +import { TransactionsRepository } from './TransactionsRepository'; +import { KnownCaip19Id, Network } from '../../constants'; +import type { State, UnencryptedStateValue } from '../state/State'; + +describe('TransactionsRepository', () => { + let transactionsRepository: TransactionsRepository; + let mockState: jest.Mocked>; + + const mockAccountId = 'test-account-id'; + + const createMockTransaction = ( + id: string, + status: TransactionStatus, + ): Transaction => ({ + id, + type: TransactionType.Send, + account: mockAccountId, + chain: Network.Mainnet, + status, + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [{ status, timestamp: Math.floor(Date.now() / 1000) }], + fees: [], + }); + + beforeEach(() => { + mockState = { + getKey: jest.fn(), + setKey: jest.fn(), + setKeyWith: jest.fn(), + update: jest.fn(), + } as unknown as jest.Mocked>; + + transactionsRepository = new TransactionsRepository(mockState); + }); + + describe('getTransactionIdsByAccountId', () => { + it('returns all transaction IDs for an account', async () => { + const transactions = [ + createMockTransaction('tx1', TransactionStatus.Confirmed), + createMockTransaction('tx2', TransactionStatus.Unconfirmed), + createMockTransaction('tx3', TransactionStatus.Failed), + ]; + mockState.getKey.mockResolvedValue(transactions); + + const result = + await transactionsRepository.getTransactionIdsByAccountId( + mockAccountId, + ); + + expect(result).toStrictEqual(new Set(['tx1', 'tx2', 'tx3'])); + }); + + it('returns empty set when no transactions exist', async () => { + mockState.getKey.mockResolvedValue(null); + + const result = + await transactionsRepository.getTransactionIdsByAccountId( + mockAccountId, + ); + + expect(result).toStrictEqual(new Set()); + }); + }); + + describe('getConfirmedTransactionIds', () => { + it('returns only confirmed and failed transaction IDs, excluding pending', async () => { + const transactions = [ + createMockTransaction('confirmed-tx', TransactionStatus.Confirmed), + createMockTransaction('pending-tx', TransactionStatus.Unconfirmed), + createMockTransaction('failed-tx', TransactionStatus.Failed), + ]; + mockState.getKey.mockResolvedValue(transactions); + + const result = + await transactionsRepository.getConfirmedTransactionIds(mockAccountId); + + // Should include confirmed and failed, but NOT unconfirmed (pending) + expect(result).toStrictEqual(new Set(['confirmed-tx', 'failed-tx'])); + expect(result.has('pending-tx')).toBe(false); + }); + + it('returns empty set when all transactions are pending', async () => { + const transactions = [ + createMockTransaction('pending-tx-1', TransactionStatus.Unconfirmed), + createMockTransaction('pending-tx-2', TransactionStatus.Unconfirmed), + ]; + mockState.getKey.mockResolvedValue(transactions); + + const result = + await transactionsRepository.getConfirmedTransactionIds(mockAccountId); + + expect(result).toStrictEqual(new Set()); + }); + + it('returns empty set when no transactions exist', async () => { + mockState.getKey.mockResolvedValue(null); + + const result = + await transactionsRepository.getConfirmedTransactionIds(mockAccountId); + + expect(result).toStrictEqual(new Set()); + }); + + it('returns all IDs when no transactions are pending', async () => { + const transactions = [ + createMockTransaction('confirmed-tx-1', TransactionStatus.Confirmed), + createMockTransaction('confirmed-tx-2', TransactionStatus.Confirmed), + createMockTransaction('failed-tx', TransactionStatus.Failed), + ]; + mockState.getKey.mockResolvedValue(transactions); + + const result = + await transactionsRepository.getConfirmedTransactionIds(mockAccountId); + + expect(result).toStrictEqual( + new Set(['confirmed-tx-1', 'confirmed-tx-2', 'failed-tx']), + ); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.ts new file mode 100644 index 00000000..aec81f62 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsRepository.ts @@ -0,0 +1,105 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { TransactionStatus } from '@metamask/keyring-api'; +import { chain } from 'lodash'; + +import type { State, UnencryptedStateValue } from '../state/State'; + +export class TransactionsRepository { + readonly #state: State; + + readonly #stateKey = 'transactions'; + + constructor(state: State) { + this.#state = state; + } + + async getAll(): Promise { + const transactionsByAccount = await this.#state.getKey< + UnencryptedStateValue['transactions'] + >(this.#stateKey); + + return Object.values(transactionsByAccount ?? {}).flat(); + } + + async findByAccountId(accountId: string): Promise { + const transactions = await this.#state.getKey( + `${this.#stateKey}.${accountId}`, + ); + + return transactions ?? []; + } + + /** + * Gets transaction IDs for an account as a Set for O(1) lookup. + * Useful for incremental sync to check which transactions already exist. + * + * @param accountId - The account ID to get transaction IDs for. + * @returns Set of transaction IDs. + */ + async getTransactionIdsByAccountId(accountId: string): Promise> { + const transactions = await this.#state.getKey( + `${this.#stateKey}.${accountId}`, + ); + + return new Set((transactions ?? []).map((tx) => tx.id)); + } + + /** + * Gets transaction IDs for confirmed (non-pending) transactions only. + * This allows pending transactions to be re-fetched and updated when + * their confirmed version becomes available from the network. + * + * @param accountId - The account ID to get confirmed transaction IDs for. + * @returns Set of transaction IDs for confirmed transactions only. + */ + async getConfirmedTransactionIds(accountId: string): Promise> { + const transactions = await this.#state.getKey( + `${this.#stateKey}.${accountId}`, + ); + + return new Set( + (transactions ?? []) + .filter((tx) => tx.status !== TransactionStatus.Unconfirmed) + .map((tx) => tx.id), + ); + } + + async save(transaction: Transaction): Promise { + await this.saveMany([transaction]); + } + + async saveMany(transactions: Transaction[]): Promise { + // Optimize the sate operations by reading and writing to the state only once + await this.#state.update((state) => { + const allTransactionsByAccount = state[this.#stateKey]; + + transactions.forEach((transaction) => { + const signature = transaction.id; + const accountId = transaction.account; + const existingTransactionsForAccount = + allTransactionsByAccount[accountId] ?? []; + + // Avoid duplicates. If a transaction with the same signature already exists, override it. + const sameSignatureTransactionIndex = + existingTransactionsForAccount.findIndex((tx) => tx.id === signature); + + if (sameSignatureTransactionIndex !== -1) { + existingTransactionsForAccount[sameSignatureTransactionIndex] = + transaction; + } + + const updatedTransactions = chain([ + ...existingTransactionsForAccount, + transaction, + ]) + .uniqBy('id') + .sortBy((item) => -(item.timestamp ?? 0)) // Sort by timestamp in descending order + .value(); + + state[this.#stateKey][accountId] = updatedTransactions; + }); + + return state; + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts new file mode 100644 index 00000000..2403dcb8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.test.ts @@ -0,0 +1,891 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { Transaction } from '@metamask/keyring-api'; +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; + +import { TransactionMapper } from './TransactionsMapper'; +import type { TransactionsRepository } from './TransactionsRepository'; +import { TransactionsService } from './TransactionsService'; +import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { + ContractTransactionInfo, + TransactionInfo, +} from '../../clients/trongrid/types'; +import { KnownCaip19Id, Network, Networks } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import type { ILogger } from '../../utils/logger'; +import nativeTransferMock from './mocks/trongrid/account-transactions/native-transfer.json'; +import trc10TransferMock from './mocks/trongrid/account-transactions/trc10-transfer.json'; +import trc20TransferMock from './mocks/trongrid/account-transactions/trc20-transfer.json'; +import contractInfoMock from './mocks/trongrid/account-trc20-transactions/contract-info.json'; + +// Import simplified mock data (each file now contains only one transaction) + +describe('TransactionsService', () => { + let transactionsService: TransactionsService; + let mockLogger: jest.Mocked; + let mockTransactionsRepository: jest.Mocked; + let mockTrongridApiClient: jest.Mocked; + let mockTronHttpClient: jest.Mocked; + + const mockAccount: TronKeyringAccount = { + id: 'test-account-id', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], + entropySource: 'test-entropy', + derivationPath: 'm/0/0', + index: 0, + }; + + const mockAccount2: TronKeyringAccount = { + id: 'test-account-id-2', + address: 'TFDP1vFeSYPT6FUznL7zUjhg5X7p2AA8vw', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], + entropySource: 'test-entropy', + derivationPath: 'm/0/1', + index: 1, + }; + + beforeEach(() => { + // Mock the global snap object + const snap = { + request: jest.fn(), + }; + (globalThis as any).snap = snap; + + // Create mocks + mockLogger = { + log: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + // Create mock repository + mockTransactionsRepository = { + getAll: jest.fn(), + findByAccountId: jest.fn().mockResolvedValue([]), + getTransactionIdsByAccountId: jest.fn().mockResolvedValue(new Set()), + getConfirmedTransactionIds: jest.fn().mockResolvedValue(new Set()), + save: jest.fn(), + saveMany: jest.fn(), + } as unknown as jest.Mocked; + + // Create mock API client + mockTrongridApiClient = { + getAccountInfoByAddress: jest.fn(), + getTransactionInfoByAddress: jest.fn(), + getContractTransactionInfoByAddress: jest.fn(), + } as unknown as jest.Mocked; + + // Create mock TronHttpClient + mockTronHttpClient = { + getTRC10TokenMetadata: jest.fn(), + getTransactionInfoById: jest.fn(), + } as unknown as jest.Mocked; + + // Create service instance + transactionsService = new TransactionsService({ + logger: mockLogger, + transactionsRepository: mockTransactionsRepository, + trongridApiClient: mockTrongridApiClient, + tronHttpClient: mockTronHttpClient, + }); + }); + + describe('checkAddressActivity', () => { + it('returns true when the address has at least one transaction', async () => { + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + nativeTransferMock, + ] as TransactionInfo[]); + + const result = await transactionsService.checkAddressActivity( + Network.Mainnet, + mockAccount.address, + ); + + expect(result).toBe(true); + expect( + mockTrongridApiClient.getTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address, { + limit: 1, + }); + }); + + it('returns false when the address has no transactions', async () => { + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([]); + + const result = await transactionsService.checkAddressActivity( + Network.Mainnet, + mockAccount.address, + ); + + expect(result).toBe(false); + }); + + it('propagates errors from the API client', async () => { + const error = new Error('API failure'); + mockTrongridApiClient.getTransactionInfoByAddress.mockRejectedValue( + error, + ); + + await expect( + transactionsService.checkAddressActivity( + Network.Mainnet, + mockAccount.address, + ), + ).rejects.toThrow('API failure'); + }); + }); + + describe('fetchNewTransactionsForAccount', () => { + it('returns mapped transactions with spam removed', async () => { + const nativeAsset = ( + amount: string, + ): Transaction['to'][number]['asset'] => ({ + type: Networks[Network.Mainnet].nativeToken.id, + amount, + unit: Networks[Network.Mainnet].nativeToken.symbol, + fungible: true, + }); + + const spamTransaction: Transaction = { + id: 'spam-tx-id', + type: TransactionType.Receive, + account: mockAccount.id, + chain: Network.Mainnet, + status: TransactionStatus.Confirmed, + timestamp: 1, + from: [{ address: 'sender-address', asset: nativeAsset('0.0005') }], + to: [{ address: mockAccount.address, asset: nativeAsset('0.0005') }], + events: [], + fees: [], + }; + + const keptTransaction: Transaction = { + ...spamTransaction, + id: 'kept-tx-id', + to: [{ address: mockAccount.address, asset: nativeAsset('0.001') }], + }; + + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + { + ...nativeTransferMock, + txID: 'spam-raw-tx-id', + }, + { + ...nativeTransferMock, + txID: 'kept-raw-tx-id', + }, + ] as TransactionInfo[]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + + const mapTransactionsSpy = jest + .spyOn(TransactionMapper, 'mapTransactions') + .mockReturnValue([spamTransaction, keptTransaction]); + + try { + const result = await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); + + expect(mapTransactionsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + scope: Network.Mainnet, + account: mockAccount, + trc20Transactions: [], + }), + ); + expect(result).toStrictEqual([keptTransaction]); + } finally { + mapTransactionsSpy.mockRestore(); + } + }); + + it('should fetch and map transactions for an account using native transfers mock data', async () => { + // Setup mock responses with simplified single-transaction structure + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + nativeTransferMock, + ] as TransactionInfo[]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + contractInfoMock.data as ContractTransactionInfo[], + ); + + await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); + + // Verify API calls were made + expect( + mockTrongridApiClient.getTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + expect( + mockTrongridApiClient.getContractTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + + // Verify logger calls + expect(mockLogger.info).toHaveBeenCalledWith( + '[🧾 TransactionsService]', + expect.stringContaining('Fetching new transactions for account'), + ); + + expect(true).toBe(true); + }); + + it('fetches and maps TRC10 transactions with token metadata', async () => { + // Setup mock responses with simplified single-transaction structure + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + trc10TransferMock, + ] as TransactionInfo[]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + // Mock TRC10 token metadata response with 3 decimals + mockTronHttpClient.getTRC10TokenMetadata.mockResolvedValue({ + name: 'BestAdsCoin', + symbol: 'TRC20AdsCOM', + decimals: 3, + }); + + const transactions = + await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount2, + ); + + // Verify API calls were made + expect( + mockTrongridApiClient.getTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount2.address); + expect( + mockTrongridApiClient.getContractTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount2.address); + + // Verify TRC10 token metadata was fetched for the token ID in the transaction + expect(mockTronHttpClient.getTRC10TokenMetadata).toHaveBeenCalledWith( + '1005119', + Network.Mainnet, + ); + + // Verify the amount and symbol are from fetched metadata + // Raw amount is 88888888, with 3 decimals: 88888888 / 10^3 = 88888.888 + expect(transactions).toHaveLength(1); + const fromAsset = transactions[0]!.from[0]!.asset as { + amount: string; + unit: string; + }; + const toAsset = transactions[0]!.to[0]!.asset as { + amount: string; + unit: string; + }; + expect(fromAsset.amount).toBe('88888.888'); + expect(fromAsset.unit).toBe('TRC20AdsCOM'); + expect(toAsset.amount).toBe('88888.888'); + expect(toAsset.unit).toBe('TRC20AdsCOM'); + }); + + it('handles decimal TRC10 asset_name values without throwing', async () => { + const trc10TransferWithDecimalTokenId = JSON.parse( + JSON.stringify(trc10TransferMock), + ) as TransactionInfo; + + const transferAssetContract = trc10TransferWithDecimalTokenId.raw_data + .contract[0] as unknown as { + parameter: { value: Record }; + }; + transferAssetContract.parameter.value.asset_name = '1005119'; + + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + trc10TransferWithDecimalTokenId, + ]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + mockTronHttpClient.getTRC10TokenMetadata.mockResolvedValue({ + name: 'BestAdsCoin', + symbol: 'TRC20AdsCOM', + decimals: 3, + }); + + const transactions = + await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount2, + ); + + expect(mockTronHttpClient.getTRC10TokenMetadata).toHaveBeenCalledWith( + '1005119', + Network.Mainnet, + ); + expect(transactions).toHaveLength(1); + }); + + it('falls back to defaults when TRC10 metadata fetch fails', async () => { + // Setup mock responses with simplified single-transaction structure + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + trc10TransferMock, + ] as TransactionInfo[]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + // Mock TRC10 token metadata to fail + mockTronHttpClient.getTRC10TokenMetadata.mockRejectedValue( + new Error('Token not found'), + ); + + const transactions = + await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount2, + ); + + // Verify TRC10 token metadata fetch was attempted + expect(mockTronHttpClient.getTRC10TokenMetadata).toHaveBeenCalledWith( + '1005119', + Network.Mainnet, + ); + + // Verify the amount falls back to default 6 decimals and symbol to UNKNOWN + // Raw amount is 88888888, with 6 decimals: 88888888 / 10^6 = 88.888888 + expect(transactions).toHaveLength(1); + const fromAsset = transactions[0]!.from[0]!.asset as { + amount: string; + unit: string; + }; + const toAsset = transactions[0]!.to[0]!.asset as { + amount: string; + unit: string; + }; + expect(fromAsset.amount).toBe('88.888888'); + expect(fromAsset.unit).toBe('UNKNOWN'); + expect(toAsset.amount).toBe('88.888888'); + expect(toAsset.unit).toBe('UNKNOWN'); + }); + + it('should handle network parameter correctly for different networks', async () => { + // Setup mock responses + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + + await transactionsService.fetchNewTransactionsForAccount( + Network.Shasta, + mockAccount, + ); + + // Verify API calls were made with correct network + expect( + mockTrongridApiClient.getTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Shasta, mockAccount.address); + expect( + mockTrongridApiClient.getContractTransactionInfoByAddress, + ).toHaveBeenCalledWith(Network.Shasta, mockAccount.address); + + expect(true).toBe(true); + }); + + it('should handle empty responses from API', async () => { + // Setup empty mock responses + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + + const result = await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); + + expect(result).toStrictEqual([]); + expect(true).toBe(true); + }); + + it('should handle API errors gracefully', async () => { + // Setup API to throw error + const apiError = new Error('API request failed'); + mockTrongridApiClient.getTransactionInfoByAddress.mockRejectedValue( + apiError, + ); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockRejectedValue( + apiError, + ); + + const result = await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); + + expect(result).toStrictEqual([]); + expect(mockLogger.error).toHaveBeenCalledWith( + '[🧾 TransactionsService]', + 'Failed to fetch raw transactions for address TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx on network tron:728126428', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + '[🧾 TransactionsService]', + 'Failed to fetch TRC20 transactions for address TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx on network tron:728126428', + ); + expect(true).toBe(true); + }); + + it('skips already confirmed transactions but allows pending transactions to be updated', async () => { + // Simulate a confirmed transaction ID that should be skipped + const confirmedTxId = 'confirmed-tx-id'; + mockTransactionsRepository.getConfirmedTransactionIds.mockResolvedValue( + new Set([confirmedTxId]), + ); + + // API returns both the confirmed tx and a new tx + const confirmedTx = { + ...nativeTransferMock, + txID: confirmedTxId, + }; + const newTx = { + ...nativeTransferMock, + txID: 'new-tx-id', + }; + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + confirmedTx, + newTx, + ] as TransactionInfo[]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + + const result = await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); + + // Should only return the new transaction, not the confirmed one + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('new-tx-id'); + }); + + it('re-fetches pending transactions so they can be updated to confirmed status', async () => { + // Simulate a pending transaction that exists in state but is NOT in confirmed set + const pendingTxId = nativeTransferMock.txID; + mockTransactionsRepository.getConfirmedTransactionIds.mockResolvedValue( + new Set(), // Pending tx ID is not in confirmed set + ); + + // API returns the same transaction (now confirmed on network) + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + nativeTransferMock, + ] as TransactionInfo[]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + + const result = await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); + + // The pending transaction should be returned so it can be updated + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe(pendingTxId); + }); + }); + + describe('findByAccounts', () => { + it('should find transactions for multiple accounts', async () => { + const mockTransactions1: Transaction[] = [ + { + id: 'tx1', + type: 'send', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [], + fees: [], + }, + ]; + + const mockTransactions2: Transaction[] = [ + { + id: 'tx2', + type: 'receive', + account: mockAccount2.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '50', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: mockAccount2.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '50', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [], + fees: [], + }, + ]; + + mockTransactionsRepository.findByAccountId + .mockResolvedValueOnce(mockTransactions1) + .mockResolvedValueOnce(mockTransactions2); + + const result = await transactionsService.findByAccounts([ + mockAccount, + mockAccount2, + ]); + + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledTimes( + 2, + ); + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockTransactionsRepository.findByAccountId).toHaveBeenCalledWith( + mockAccount2.id, + ); + expect(result).toHaveLength(2); + expect(true).toBe(true); + }); + + it('should handle empty accounts array', async () => { + const result = await transactionsService.findByAccounts([]); + + expect(result).toStrictEqual([]); + expect(mockTransactionsRepository.findByAccountId).not.toHaveBeenCalled(); + expect(true).toBe(true); + }); + }); + + describe('save', () => { + it('should save a single transaction', async () => { + const mockTransaction: Transaction = { + id: 'tx-save-test', + type: 'send', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [], + fees: [], + }; + + await transactionsService.save(mockTransaction); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([ + mockTransaction, + ]); + expect(true).toBe(true); + }); + }); + + describe('saveMany', () => { + it('should save multiple transactions and emit keyring event', async () => { + const mockTransactions: Transaction[] = [ + { + id: 'tx-bulk-1', + type: 'send', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [], + fees: [], + }, + { + id: 'tx-bulk-2', + type: 'receive', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '50', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '50', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [], + fees: [], + }, + ]; + + await transactionsService.saveMany(mockTransactions); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( + mockTransactions, + ); + expect(true).toBe(true); + }); + + it('should handle empty transactions array', async () => { + await transactionsService.saveMany([]); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith([]); + expect(true).toBe(true); + }); + + it('should group transactions by account ID correctly', async () => { + const mockTransactions: Transaction[] = [ + { + id: 'tx-account1-1', + type: 'send', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '100', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [], + fees: [], + }, + { + id: 'tx-account1-2', + type: 'receive', + account: mockAccount.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '25', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: mockAccount.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '25', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [], + fees: [], + }, + { + id: 'tx-account2-1', + type: 'send', + account: mockAccount2.id, + chain: Network.Mainnet, + status: 'confirmed', + timestamp: Math.floor(Date.now() / 1000), + from: [ + { + address: mockAccount2.address, + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '75', + unit: 'TRX', + fungible: true, + }, + }, + ], + to: [ + { + address: 'other-address', + asset: { + type: KnownCaip19Id.TrxMainnet, + amount: '75', + unit: 'TRX', + fungible: true, + }, + }, + ], + events: [], + fees: [], + }, + ]; + + await transactionsService.saveMany(mockTransactions); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( + mockTransactions, + ); + expect(true).toBe(true); + }); + }); + + describe('Integration scenarios', () => { + it('should handle a complete flow: fetch, process, and save transactions', async () => { + // Setup API responses with simplified single-transaction structure + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue([ + nativeTransferMock, + ] as TransactionInfo[]); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + contractInfoMock.data.slice(0, 1) as ContractTransactionInfo[], + ); + + // Fetch transactions + const fetchedTransactions = + await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount, + ); + + // Save the fetched transactions + await transactionsService.saveMany(fetchedTransactions); + + expect(mockTransactionsRepository.saveMany).toHaveBeenCalledWith( + fetchedTransactions, + ); + expect(true).toBe(true); + }); + + it('should handle mixed transaction types from different mock data sources', async () => { + // Mix different types of transactions with simplified structure + const mixedRawTransactions = [ + nativeTransferMock, // Native TRX transfer + trc10TransferMock, // TRC10 transfer + trc20TransferMock, // TRC20 transfer + ] as TransactionInfo[]; + + mockTrongridApiClient.getTransactionInfoByAddress.mockResolvedValue( + mixedRawTransactions, + ); + mockTrongridApiClient.getContractTransactionInfoByAddress.mockResolvedValue( + [], + ); + + await transactionsService.fetchNewTransactionsForAccount( + Network.Mainnet, + mockAccount2, + ); + + expect(true).toBe(true); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts new file mode 100644 index 00000000..913d11f7 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/TransactionsService.ts @@ -0,0 +1,412 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { groupBy } from 'lodash'; + +import { TransactionMapper } from './TransactionsMapper'; +import type { TransactionsRepository } from './TransactionsRepository'; +import { isSpam } from './utils/isSpam'; +import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; +import type { TRC10TokenMetadata } from '../../clients/tron-http/types'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { + ContractTransactionInfo, + TransactionInfo, + TransferAssetContractInfo, +} from '../../clients/trongrid/types'; +import type { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; + +export class TransactionsService { + readonly #logger: ILogger; + + readonly #transactionsRepository: TransactionsRepository; + + readonly #trongridApiClient: TrongridApiClient; + + readonly #tronHttpClient: TronHttpClient; + + constructor({ + logger, + transactionsRepository, + trongridApiClient, + tronHttpClient, + }: { + logger: ILogger; + transactionsRepository: TransactionsRepository; + trongridApiClient: TrongridApiClient; + tronHttpClient: TronHttpClient; + }) { + this.#logger = createPrefixedLogger(logger, '[🧾 TransactionsService]'); + this.#transactionsRepository = transactionsRepository; + this.#trongridApiClient = trongridApiClient; + this.#tronHttpClient = tronHttpClient; + } + + /** + * Enriches potential swap transactions with full internal_transactions data. + * TronGrid's /transactions endpoint often returns empty internal_transactions, + * but we need this data for accurate TRX↔TRC20 swap detection. + * + * @param scope - The network scope. + * @param rawTransactions - Array of transactions to potentially enrich. + * @returns Array of enriched transactions. + */ + async #enrichPotentialSwaps( + scope: Network, + rawTransactions: TransactionInfo[], + ): Promise { + const enrichmentPromises = rawTransactions.map(async (tx) => { + // Only enrich TriggerSmartContract transactions that might be swaps + // and are missing internal_transactions from TronGrid's /transactions endpoint. + // We also check for call_value > 0 as these are potential TRX swaps. + const isTriggerSmartContract = + tx.raw_data.contract?.[0]?.type === 'TriggerSmartContract'; + const hasEmptyInternalTransactions = + !tx.internal_transactions || tx.internal_transactions.length === 0; + const hasCallValue = + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tx.raw_data.contract?.[0]?.parameter?.value as any)?.call_value > 0; + + if ( + isTriggerSmartContract && + hasEmptyInternalTransactions && + hasCallValue + ) { + this.#logger.debug( + `Attempting to enrich transaction ${tx.txID} with full details for swap detection.`, + ); + try { + const fullTxInfo = await this.#tronHttpClient.getTransactionInfoById( + scope, + tx.txID, + ); + + if (fullTxInfo) { + // Merge internal_transactions from fullTxInfo into the existing tx + return { + ...tx, + // eslint-disable-next-line @typescript-eslint/naming-convention + internal_transactions: fullTxInfo.internal_transactions ?? [], + }; + } + } catch (error) { + this.#logger.warn( + { txId: tx.txID, error }, + `Failed to enrich transaction ${tx.txID}`, + ); + } + } + return tx; // Return original transaction if not enriched or on error + }); + + const enrichedTransactions = await Promise.allSettled(enrichmentPromises); + + return enrichedTransactions + .map((result) => (result.status === 'fulfilled' ? result.value : null)) + .filter(Boolean) as TransactionInfo[]; + } + + /** + * Extracts unique TRC10 token IDs from raw transactions and fetches their metadata. + * This is used to get the correct decimal precision for TRC10 token amount conversions. + * + * @param scope - The network scope. + * @param rawTransactions - Array of raw transactions to scan for TRC10 transfers. + * @returns Map of token ID to TRC10 token metadata. + */ + async #fetchTrc10TokenMetadata( + scope: Network, + rawTransactions: TransactionInfo[], + ): Promise> { + const trc10TokenMetadata = new Map(); + + // Extract unique TRC10 token IDs from TransferAssetContract transactions + const trc10TokenIds = new Set(); + + for (const tx of rawTransactions) { + const contract = tx.raw_data.contract?.[0]; + if (contract?.type === 'TransferAssetContract') { + const assetContract = contract as TransferAssetContractInfo; + const tokenId = assetContract.parameter.value.asset_name; + trc10TokenIds.add(tokenId); + } + } + + if (trc10TokenIds.size === 0) { + return trc10TokenMetadata; + } + + this.#logger.debug( + `Fetching metadata for ${trc10TokenIds.size} TRC10 tokens...`, + ); + + // Fetch metadata for each unique token ID + const metadataPromises = Array.from(trc10TokenIds).map(async (tokenId) => { + try { + const metadata = await this.#tronHttpClient.getTRC10TokenMetadata( + tokenId, + scope, + ); + return { tokenId, metadata }; + } catch (error) { + this.#logger.warn( + { tokenId, error }, + `Failed to fetch TRC10 token metadata for ${tokenId}, will use default decimals`, + ); + return null; + } + }); + + const results = await Promise.allSettled(metadataPromises); + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + trc10TokenMetadata.set(result.value.tokenId, result.value.metadata); + } + } + + this.#logger.debug( + `Successfully fetched metadata for ${trc10TokenMetadata.size}/${trc10TokenIds.size} TRC10 tokens.`, + ); + + return trc10TokenMetadata; + } + + /** + * Fetches and processes only NEW transactions for an account. + * + * This function implements incremental sync - it only processes transactions + * that are not already in state, avoiding redundant API calls for: + * - getTransactionInfoById (swap enrichment) + * - getTRC10TokenMetadata (token decimals) + * + * The caller is responsible for saving the returned transactions to state + * via `saveMany()`, which will merge them with existing transactions. + * + * @param scope - The network scope. + * @param account - The account to fetch transactions for. + * @returns Only NEW transactions (not already in state). Empty array if none. + */ + async fetchNewTransactionsForAccount( + scope: Network, + account: TronKeyringAccount, + ): Promise { + this.#logger.info( + `Fetching new transactions for account ${account.address} on network ${scope}...`, + ); + + /** + * Step 1: Load confirmed transaction IDs from state + * + * We only skip transactions that are already confirmed. Pending (Unconfirmed) + * transactions are allowed to be re-fetched so their status can be updated + * when they become confirmed on the network. + */ + const confirmedTxIds = + await this.#transactionsRepository.getConfirmedTransactionIds(account.id); + + this.#logger.debug( + `Found ${confirmedTxIds.size} confirmed transactions in state for account ${account.id}.`, + ); + + /** + * Step 2: Fetch raw transaction list from TronGrid + * + * Fetch the foundational transaction data from TronGrid in parallel: + * - Raw transactions: Primary source for all transaction types + * - TRC20 transactions: Assistance data for smart contract parsing + */ + const { rawTransactions, trc20Transactions } = + await this.#fetchRawTransactionData(scope, account.address); + + this.#logger.info( + `Fetched ${rawTransactions.length} raw transactions and ${trc20Transactions.length} TRC20 assistance data for account ${account.address} on network ${scope}.`, + ); + + /** + * Step 3: Filter to transactions that need processing + * + * Skip transactions that are already confirmed in state. + * Pending transactions are included so they can be updated when confirmed. + * This maintains the optimization while allowing status updates. + */ + const transactionsToProcess = rawTransactions.filter( + (tx) => !confirmedTxIds.has(tx.txID), + ); + + this.#logger.info( + `Found ${transactionsToProcess.length} transactions to process (${rawTransactions.length - transactionsToProcess.length} already confirmed in state).`, + ); + + /** + * Step 4: If no transactions to process, return empty array + * + * This is the fast path - skip all enrichment and mapping. + */ + if (transactionsToProcess.length === 0) { + this.#logger.info(`No transactions to process.`); + return []; + } + + /** + * Step 5: Process transactions (enrichment + mapping) + * + * Enrich potential swaps with internal_transactions data + * and fetch TRC10 token metadata for transactions to process. + */ + const { enrichedRawTransactions, trc10TokenMetadata } = + await this.#fetchEnrichmentData(scope, transactionsToProcess); + + this.#logger.info( + `Enriched ${enrichedRawTransactions.length} transactions, fetched metadata for ${trc10TokenMetadata.size} TRC10 tokens.`, + ); + + /** + * Step 6: Map transactions to keyring format + * + * Filter TRC20 assistance data to only include transactions matching processed txs. + */ + const processedTxIds = new Set(transactionsToProcess.map((tx) => tx.txID)); + const relevantTrc20Transactions = trc20Transactions.filter((tx) => + processedTxIds.has(tx.transaction_id), + ); + + const mappedTransactions = TransactionMapper.mapTransactions({ + scope, + account, + rawTransactions: enrichedRawTransactions, + trc20Transactions: relevantTrc20Transactions, + trc10TokenMetadata, + }); + + const filteredTransactions = mappedTransactions.filter( + (transaction) => !isSpam(transaction, account), + ); + + this.#logger.info( + `Returning ${filteredTransactions.length} transactions for account ${account.address} on network ${scope}.`, + ); + + return filteredTransactions; + } + + /** + * Returns whether the address has any on-chain transaction history on the scope. + * Uses a minimal TronGrid page size when supported (`limit=1`). + * + * @param scope - The network scope. + * @param address - The account address to check. + * @returns True when at least one transaction is returned for the address on this scope. + */ + async checkAddressActivity( + scope: Network, + address: string, + ): Promise { + const transactions = + await this.#trongridApiClient.getTransactionInfoByAddress( + scope, + address, + { limit: 1 }, + ); + return transactions.length > 0; + } + + /** + * Fetches the foundational transaction data from TronGrid. + * Both requests run in parallel for optimal performance. + * + * @param scope - The network scope. + * @param address - The account address to fetch transactions for. + * @returns Raw transactions and TRC20 transactions. + */ + async #fetchRawTransactionData( + scope: Network, + address: string, + ): Promise<{ + rawTransactions: TransactionInfo[]; + trc20Transactions: ContractTransactionInfo[]; + }> { + const [rawTransactionsResult, trc20TransactionsResult] = + await Promise.allSettled([ + this.#trongridApiClient.getTransactionInfoByAddress(scope, address), + this.#trongridApiClient.getContractTransactionInfoByAddress( + scope, + address, + ), + ]); + + let rawTransactions: TransactionInfo[] = []; + let trc20Transactions: ContractTransactionInfo[] = []; + + if (rawTransactionsResult.status === 'rejected') { + this.#logger.error( + `Failed to fetch raw transactions for address ${address} on network ${scope}`, + ); + } else { + rawTransactions = rawTransactionsResult.value; + } + + if (trc20TransactionsResult.status === 'rejected') { + this.#logger.error( + `Failed to fetch TRC20 transactions for address ${address} on network ${scope}`, + ); + } else { + trc20Transactions = trc20TransactionsResult.value; + } + + return { rawTransactions, trc20Transactions }; + } + + /** + * Fetches enrichment data based on the raw transactions. + * Both enrichment operations run in parallel since they scan for different contract types: + * - Swap enrichment scans for TriggerSmartContract + * - TRC10 metadata scans for TransferAssetContract + * + * @param scope - The network scope. + * @param rawTransactions - The raw transactions to enrich. + * @returns Enriched transactions and TRC10 token metadata. + */ + async #fetchEnrichmentData( + scope: Network, + rawTransactions: TransactionInfo[], + ): Promise<{ + enrichedRawTransactions: TransactionInfo[]; + trc10TokenMetadata: Map; + }> { + const [enrichedRawTransactions, trc10TokenMetadata] = await Promise.all([ + this.#enrichPotentialSwaps(scope, rawTransactions), + this.#fetchTrc10TokenMetadata(scope, rawTransactions), + ]); + + return { enrichedRawTransactions, trc10TokenMetadata }; + } + + async findByAccounts(accounts: TronKeyringAccount[]): Promise { + const transactions = await Promise.all( + accounts.map(async (account) => + this.#transactionsRepository.findByAccountId(account.id), + ), + ); + + return transactions.flat(); + } + + async save(transaction: Transaction): Promise { + await this.saveMany([transaction]); + } + + async saveMany(transactions: Transaction[]): Promise { + await this.#transactionsRepository.saveMany(transactions); + + const transactionsByAccountId = groupBy(transactions, 'account'); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { + transactions: transactionsByAccountId, + }); + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/tron-http/gettransactioninfobyid/swap-transaction.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/tron-http/gettransactioninfobyid/swap-transaction.json new file mode 100644 index 00000000..bea4068b --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/tron-http/gettransactioninfobyid/swap-transaction.json @@ -0,0 +1,508 @@ +{ + "id": "7b6589b22ca801e35fd5d98186e019757875d3fb52c0bdc38a7cf4b2f24d9808", + "fee": 72646600, + "blockNumber": 77641497, + "blockTimeStamp": 1763586027000, + "contractResult": [ + "00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000009740b40000000000000000000000000000000000000000000000003fcbdf04566f1e1000000000000000000000000000000000000000000000000028a0a71fc628a41c00000000000000000000000000000000000000000000000000000000002c8061" + ], + "contract_address": "41f742f4589459f0923fa579600815763d1646bec3", + "receipt": { + "energy_fee": 70437600, + "energy_usage_total": 704376, + "net_fee": 2209000, + "result": "SUCCESS", + "energy_penalty_total": 162778 + }, + "log": [ + { + "address": "f742f4589459f0923fa579600815763d1646bec3", + "topics": [ + "df4363408b2d9811d1e5c23efdb5bae0b7a68bd9de2de1cbae18a11be3e67ef5" + ], + "data": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000155cc00000000000000000000000070b0f2955dde7dca6567187d0a74d532043fc2a2" + }, + { + "address": "f742f4589459f0923fa579600815763d1646bec3", + "topics": [ + "f14fbd8b6e3ad3ae34babfa1f3b6a099f57643662f4cfc24eb335ae8718f534b", + "00000000000000000000000070b0f2955dde7dca6567187d0a74d532043fc2a2", + "00000000000000000000000000000000000000000000000000000000000003e9" + ], + "data": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000155cc" + }, + { + "address": "834295921a488d9d42b4b3021ed1a3c39fb0f03e", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "000000000000000000000000a0a9d57ee9df8308bc29bcf881a569305260a0a5", + "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd" + ], + "data": "0000000000000000000000000000000000000000000000003fcbdf04566f1e10" + }, + { + "address": "a0a9d57ee9df8308bc29bcf881a569305260a0a5", + "topics": [ + "cd60aa75dea3072fbc07ae6d7d856b5dc5f4eee88854f5b4abf7b680ef8bc50f", + "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "00000000000000000000000000000000000000000000000000000000009740b4", + "0000000000000000000000000000000000000000000000003fcbdf04566f1e10" + ] + }, + { + "address": "a0a9d57ee9df8308bc29bcf881a569305260a0a5", + "topics": [ + "cc7244d3535e7639366f8c5211527112e01de3ec7449ee3a6e66b007f4065a70", + "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "00000000000000000000000000000000000000000000000000000057b513a06f", + "00000000000000000000000000000000000000000000251aa6f23c55a6f82efd" + ] + }, + { + "address": "834295921a488d9d42b4b3021ed1a3c39fb0f03e", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "000000000000000000000000651c7b328d7b72cc5e8f287dc4ed3b3355f096ba" + ], + "data": "0000000000000000000000000000000000000000000000003fcbdf04566f1e10" + }, + { + "address": "dbd0a4077ca43b89feae6ccc9e346ac1dacaacee", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "000000000000000000000000651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "0000000000000000000000000000000000000000000000000000000000000000" + ], + "data": "000000000000000000000000000000000000000000000000288b9df55adf6c36" + }, + { + "address": "dbd0a4077ca43b89feae6ccc9e346ac1dacaacee", + "topics": [ + "8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "000000000000000000000000651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "000000000000000000000000b149d394a9e2f3d20002b3f768b8cdd8584dbebc" + ], + "data": "ffffffffffffffffffffffffffffffffffffffffffe7895a2cde8babfa225ca2" + }, + { + "address": "cebde71077b830b958c8da17bcddeeb85d0bcf25", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "000000000000000000000000b149d394a9e2f3d20002b3f768b8cdd8584dbebc", + "000000000000000000000000651c7b328d7b72cc5e8f287dc4ed3b3355f096ba" + ], + "data": "00000000000000000000000000000000000000000000000028a0a71fc628a41c" + }, + { + "address": "b149d394a9e2f3d20002b3f768b8cdd8584dbebc", + "topics": [ + "9e96dd3b997a2a257eec4df9bb6eaf626e206df5f543bd963682d143300be310", + "000000000000000000000000651c7b328d7b72cc5e8f287dc4ed3b3355f096ba" + ], + "data": "000000000000000000000000000000000000000000000000288b9df55adf6c3600000000000000000000000000000000000000000000000028a0a71fc628a41c" + }, + { + "address": "cebde71077b830b958c8da17bcddeeb85d0bcf25", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "000000000000000000000000651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd" + ], + "data": "00000000000000000000000000000000000000000000000028a0a71fc628a41c" + }, + { + "address": "651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "topics": [ + "d013ca23e77a65003c2c659c5442c00c805371b7fc1ebd4c206c41d1536bd90b", + "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd" + ], + "data": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003fcbdf04566f1e10000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000028a0a71fc628a41c" + }, + { + "address": "a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "000000000000000000000000dd038f617a057cd1ad6fa795f026265bed391a67", + "000000000000000000000000f742f4589459f0923fa579600815763d1646bec3" + ], + "data": "00000000000000000000000000000000000000000000000000000000002c8061" + }, + { + "address": "cebde71077b830b958c8da17bcddeeb85d0bcf25", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "000000000000000000000000dd038f617a057cd1ad6fa795f026265bed391a67" + ], + "data": "00000000000000000000000000000000000000000000000028a0a71fc628a41c" + }, + { + "address": "cebde71077b830b958c8da17bcddeeb85d0bcf25", + "topics": [ + "8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "0000000000000000000000009bc8fbecba5d240b977b6fd4e9ac25b0012ef843" + ], + "data": "fffffffffffffffffffffffffffffffffffffffffffbd9c48fcaba3dd7301c1e" + }, + { + "address": "dd038f617a057cd1ad6fa795f026265bed391a67", + "topics": [ + "c42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67", + "0000000000000000000000009bc8fbecba5d240b977b6fd4e9ac25b0012ef843", + "000000000000000000000000f742f4589459f0923fa579600815763d1646bec3" + ], + "data": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd37f9f00000000000000000000000000000000000000000000000028a0a71fc628a41c00000000000000000000000000000000000f4976a5c73cbde0c1a3301c3687cb000000000000000000000000000000000000000000000009f84f6b62002586c40000000000000000000000000000000000000000000000000000000000043788" + }, + { + "address": "18ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "topics": [ + "a3f636db0b76da13346d04e9a8970b81e1900067a475fea94400232d170f89c2", + "000000000000000000000000f742f4589459f0923fa579600815763d1646bec3", + "00000000000000000000000000000000000000000000000000000000009740b4" + ], + "data": "0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000009740b40000000000000000000000000000000000000000000000003fcbdf04566f1e1000000000000000000000000000000000000000000000000028a0a71fc628a41c00000000000000000000000000000000000000000000000000000000002c8061" + }, + { + "address": "f742f4589459f0923fa579600815763d1646bec3", + "topics": [ + "2fc0d44e6ef6b3e7707cacd3cc326511198c3d1598c65dd54be5a9e37ce02f12" + ], + "data": "00000000000000000000000018ff186cb1973d4b29700f2aac6b1eec9e55ffbd0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000009740b40000000000000000000000000000000000000000000000003fcbdf04566f1e1000000000000000000000000000000000000000000000000028a0a71fc628a41c00000000000000000000000000000000000000000000000000000000002c8061" + }, + { + "address": "f742f4589459f0923fa579600815763d1646bec3", + "topics": [ + "0e9201911743fd4d03e146f00ad23945dc8f3ffc200906eff25179a52b726f17", + "00000000000000000000000000000000eab935fc82494e1dac6e02c13096bd1d", + "00000000000000000000000000000000000000000000000000000000000003e9" + ], + "data": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a614f803b6fd780986a42c78ec9c7f77e6ded13c00000000000000000000000000000000000000000000000000000000009740b400000000000000000000000000000000000000000000000000000000002b9c8800000000000000000000000000000000000000000000000000000000002c8061000000000000000000000000b7dee733611e26b1876a8c353e4e5ec023530ac700000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000084d6574614d61736b000000000000000000000000000000000000000000000000" + }, + { + "address": "f742f4589459f0923fa579600815763d1646bec3", + "topics": [ + "df4363408b2d9811d1e5c23efdb5bae0b7a68bd9de2de1cbae18a11be3e67ef5" + ], + "data": "000000000000000000000000a614f803b6fd780986a42c78ec9c7f77e6ded13c00000000000000000000000000000000000000000000000000000000002c8061000000000000000000000000b7dee733611e26b1876a8c353e4e5ec023530ac7" + }, + { + "address": "a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "topics": [ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "000000000000000000000000f742f4589459f0923fa579600815763d1646bec3", + "000000000000000000000000b7dee733611e26b1876a8c353e4e5ec023530ac7" + ], + "data": "00000000000000000000000000000000000000000000000000000000002c8061" + } + ], + "internal_transactions": [ + { + "hash": "264bed78ac45498540ca44f1fc82b083d3f63a321ae8866de1c12a0f12e69ed5", + "caller_address": "41f742f4589459f0923fa579600815763d1646bec3", + "transferTo_address": "41f742f4589459f0923fa579600815763d1646bec3", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "e492f0e1ee253f58b9fabe109a380bf1fc34ff9113920d12fcd0749aa9c9b75c", + "caller_address": "41f742f4589459f0923fa579600815763d1646bec3", + "transferTo_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "0723cf24fbc9e389fd08689d065dc91faca5a361a14288156f92420802c0cd8e", + "caller_address": "41f742f4589459f0923fa579600815763d1646bec3", + "transferTo_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "a00dc01a55e7b2babebdf13d2c94c36ae584ffe1956bf5274db879dca9839133", + "caller_address": "41f742f4589459f0923fa579600815763d1646bec3", + "transferTo_address": "4170b0f2955dde7dca6567187d0a74d532043fc2a2", + "callValueInfo": [ + { + "callValue": 87500 + } + ], + "note": "63616c6c" + }, + { + "hash": "08231878f4b258e31570654a6f3705ba734355b91e31ceb5812334812c35cd5d", + "caller_address": "41f742f4589459f0923fa579600815763d1646bec3", + "transferTo_address": "4118ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "callValueInfo": [ + { + "callValue": 9912500 + } + ], + "note": "63616c6c" + }, + { + "hash": "a213088ed524d98206665012160e4362d2b23ebe6d7124d0b6ccaef1ca2955a7", + "caller_address": "4118ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "transferTo_address": "41eed9e56a5cddaa15ef0c42984884a8afcf1bdebb", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "c4f4b26648a1105a606463c6467a7edcf9fa4c77133eeea20c261bec33963f8b", + "caller_address": "4118ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "transferTo_address": "41a0a9d57ee9df8308bc29bcf881a569305260a0a5", + "callValueInfo": [ + { + "callValue": 9912500 + } + ], + "note": "63616c6c" + }, + { + "hash": "f4ac619641f0e3e318a8048e0dd1bb79c6905ba3f27428d6d7a4f0afeb720bc6", + "caller_address": "41a0a9d57ee9df8308bc29bcf881a569305260a0a5", + "transferTo_address": "41834295921a488d9d42b4b3021ed1a3c39fb0f03e", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "dca1d8350acd11736cc4587215e8de5a6e4ad474d80efb89cd7081c530f000be", + "caller_address": "41a0a9d57ee9df8308bc29bcf881a569305260a0a5", + "transferTo_address": "41834295921a488d9d42b4b3021ed1a3c39fb0f03e", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "7acdd4225d854d9cd0676bed09938a7d8cd0c331a2223752e62587abde194e49", + "caller_address": "41a0a9d57ee9df8308bc29bcf881a569305260a0a5", + "transferTo_address": "41834295921a488d9d42b4b3021ed1a3c39fb0f03e", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "33ca22962033ce661416449a5b02f56ff9674fc0d1308ffda7bde863d46ee92d", + "caller_address": "4118ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "219568363714e5e89522b933aec77a58ba923b63f25d9f7acbe3e0664443154c", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "a75c3ff84229eed9ff2577c9c2dab7598e3ad94567b9f0dd1c8ddc0731747426", + "caller_address": "4118ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "transferTo_address": "41651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "193720fe3c2ef0110736fbc864e90ffc94b6dc04b16121a8ce0cac7bee92e06a", + "caller_address": "41651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "transferTo_address": "41b149d394a9e2f3d20002b3f768b8cdd8584dbebc", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "3a2f5800eba43a45ace30b0f2c1411776b8aa68b1c68a3ffefd3fc0815e77e74", + "caller_address": "41b149d394a9e2f3d20002b3f768b8cdd8584dbebc", + "transferTo_address": "41dbd0a4077ca43b89feae6ccc9e346ac1dacaacee", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "39311d4f31625a30821c054a6664a62278eba2869b5b59474eb9d55ea78a1102", + "caller_address": "41651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "transferTo_address": "41834295921a488d9d42b4b3021ed1a3c39fb0f03e", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "8c6a82d466f0ed97fd30393b23cc3ffdb8f28a610a00c0567c39195cd489d6e1", + "caller_address": "41651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "1cd1bb6e2e3076353edd63138e350d0834ea82fe6971354bdb72f0942f230bb8", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "75ad67ccc645af03081783216810c9ab68839db62142cb580b9719933793af08", + "caller_address": "41651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "transferTo_address": "41b149d394a9e2f3d20002b3f768b8cdd8584dbebc", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "37a914c278deef4d761349c4c9d2e7ecac480d4ceb32e36ff660ef579af7b252", + "caller_address": "41b149d394a9e2f3d20002b3f768b8cdd8584dbebc", + "transferTo_address": "41dbd0a4077ca43b89feae6ccc9e346ac1dacaacee", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "b4b457b8db484209b792c942170bf878418e177cfae4f0d094308a83b810b892", + "caller_address": "41b149d394a9e2f3d20002b3f768b8cdd8584dbebc", + "transferTo_address": "41dbd0a4077ca43b89feae6ccc9e346ac1dacaacee", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "e90adc41cd853697b63f1fb216f5a1f2033345764379ae8389a92e406ea9114f", + "caller_address": "41b149d394a9e2f3d20002b3f768b8cdd8584dbebc", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "8549979a139930e50e163d807f8243630fa25f676647d012173bfb80b9576649", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "987c42e24edc57d9a4d7fb8388d54fbfb60d2bd13cb978dfecaf7036e02a5e01", + "caller_address": "41651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "c0d1403090ab69dbd60f213e98cab6374c08e979a58366c03b945f9ce1af6e09", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "43e9365db7dcfcf054841d47b75d1f3ceee836999c473d93be12e376a6e2112d", + "caller_address": "41651c7b328d7b72cc5e8f287dc4ed3b3355f096ba", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "be2be2522f89303625d4296dcf95325874625b9c92433e6e61e773cff65b204a", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "04954b3b50f767e925648a0a16cb2c368a8757d0556b41e79f05652d95cbd160", + "caller_address": "4118ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "5e640cb7d4011c0eb5171ca2ce425a4e3da34de33d726d2ace140e7c718e35d0", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "365bfa1e39b732b43aefff3579514f142ab4d64d688725ce57e9a301d5d0f5f0", + "caller_address": "4118ff186cb1973d4b29700f2aac6b1eec9e55ffbd", + "transferTo_address": "419bc8fbecba5d240b977b6fd4e9ac25b0012ef843", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "17367c11e32c29b583e67dbc0a342ed4862a837d5794aba99cfa66aa3d48490d", + "caller_address": "419bc8fbecba5d240b977b6fd4e9ac25b0012ef843", + "transferTo_address": "41dd038f617a057cd1ad6fa795f026265bed391a67", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "e5be147f814df5778fce7afc2d409b6a1d9bbea1fef543cc17968598bd6e136f", + "caller_address": "41dd038f617a057cd1ad6fa795f026265bed391a67", + "transferTo_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "7d57934dbf29e2ca5a00b481096024503e017548b38bb5df950235cc757267ed", + "caller_address": "41dd038f617a057cd1ad6fa795f026265bed391a67", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "dbd9844a240b36709dd60bf10ae041f9fdf8b53bddb08d3d43fde093819f65c1", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "9a1b000752045868326adfe76d9fe3afe4465de99a2086acdc20567af267cdb7", + "caller_address": "41dd038f617a057cd1ad6fa795f026265bed391a67", + "transferTo_address": "419bc8fbecba5d240b977b6fd4e9ac25b0012ef843", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "2540be0eb9c55ee9386c799a4d43c084fa3d802d8bff5ea5a9cd4b699c21e34a", + "caller_address": "419bc8fbecba5d240b977b6fd4e9ac25b0012ef843", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "c6b7f1a8c772accee4e471ee01a2cada4c36636f7cb43a778935c01c315c55f8", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "b32140237ba0fb2a64a9196b2bae9d925e792d95d57ba1549fe23020e5aaa030", + "caller_address": "41dd038f617a057cd1ad6fa795f026265bed391a67", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "9af721d3cc2cd2169f13a35358714c4dca8ec3bd824bc7acc08230ae77306881", + "caller_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "transferTo_address": "41cebde71077b830b958c8da17bcddeeb85d0bcf25", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "15126c849a64cd6d0f4b5b2c279f3f4cbb778b134e7cb5b9a323c3635d3c1efa", + "caller_address": "41f742f4589459f0923fa579600815763d1646bec3", + "transferTo_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "77b191d44c6b3e3da481d1e52a01e11415e44ff641df0c68818b14afa85537e8", + "caller_address": "41f742f4589459f0923fa579600815763d1646bec3", + "transferTo_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "callValueInfo": [{}], + "note": "63616c6c" + }, + { + "hash": "e9f61d9f87c92ee51c331ef654c4b4d8da150a8de00bcee2518a9632f08fd7ea", + "caller_address": "41f742f4589459f0923fa579600815763d1646bec3", + "transferTo_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "callValueInfo": [{}], + "note": "63616c6c" + } + ] +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/failed-transaction.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/failed-transaction.json new file mode 100644 index 00000000..29793323 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/failed-transaction.json @@ -0,0 +1,38 @@ +{ + "ret": [ + { + "contractRet": "OUT_OF_ENERGY", + "fee": 54171200 + } + ], + "signature": ["placeholder_signature"], + "txID": "3892c8061153a45058de63b356dd84dd24410593dc8211311000732e09707205", + "net_usage": 0, + "raw_data_hex": "placeholder", + "net_fee": 2209000, + "energy_usage": 0, + "blockNumber": 77641671, + "block_timestamp": 1763586549000, + "energy_fee": 51962200, + "energy_usage_total": 519622, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "placeholder_data", + "owner_address": "412efffc7686e54ab669a1cdb1e2cc17cf4b4eca96", + "contract_address": "41f742f4589459f0923fa579600815763d1646bec3" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "b30a", + "ref_block_hash": "fb8cad0722557435", + "expiration": 1763586606000, + "fee_limit": 100000000, + "timestamp": 1763586549313 + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/native-transfer-without-timestamp.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/native-transfer-without-timestamp.json new file mode 100644 index 00000000..4cd97ce4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/native-transfer-without-timestamp.json @@ -0,0 +1,39 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 0 + } + ], + "signature": [ + "813bc2c5f202424fd5e7d5b15705c080d030385a69df3bd488085a64590a288bb27a9607babf6b11535d0ed11259a99c9365bac7602ba847c808504854e0e89f00" + ], + "txID": "c4e8c4a45830e882e92062ede0ecd09702c51e4732385b9cf33590d470b08357", + "net_usage": 257, + "raw_data_hex": "0a02f8a322083f19277552b64a1f40d0d8b8aed2335a65080112610a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412300a15418096951000b00ec39359133a30a502d1e6e23f77121541458437be39f3a8bfdbfee7bef93e2c5f632ceff41801", + "net_fee": 0, + "energy_usage": 0, + "blockNumber": 81262756, + "block_timestamp": 1774455465000, + "energy_fee": 0, + "energy_usage_total": 0, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 1, + "owner_address": "418096951000b00ec39359133a30a502d1e6e23f77", + "to_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4" + }, + "type_url": "type.googleapis.com/protocol.TransferContract" + }, + "type": "TransferContract" + } + ], + "ref_block_bytes": "f8a3", + "ref_block_hash": "3f19277552b64a1f", + "expiration": 1774455762000 + }, + "internal_transactions": [] +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/native-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/native-transfer.json new file mode 100644 index 00000000..db1ee0f0 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/native-transfer.json @@ -0,0 +1,40 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 266000 + } + ], + "signature": [ + "128b88278e273e5b1f89c6f0e1047af67406206bc5c848229d072dede4303ba90ccc25393003fc4346d7aaa8c0d44570d3f86510fa74e6435024484e5fec77de1b" + ], + "txID": "8145535b24f71bc592b8ab2d94e91a30d12f74ab33fa4aab2ff2a27b767fc49b", + "net_usage": 0, + "raw_data_hex": "0a02cb1d2208d3fc65a6fb0f782d40e8b2a18291335a66080112620a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412310a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff41215412efffc7686e54ab669a1cdb1e2cc17cf4b4eca9618904e7088de9d829133", + "net_fee": 266000, + "energy_usage": 0, + "blockNumber": 75418399, + "block_timestamp": 1756914747000, + "energy_fee": 0, + "energy_usage_total": 0, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 10000, + "owner_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4", + "to_address": "412efffc7686e54ab669a1cdb1e2cc17cf4b4eca96" + }, + "type_url": "type.googleapis.com/protocol.TransferContract" + }, + "type": "TransferContract" + } + ], + "ref_block_bytes": "cb1d", + "ref_block_hash": "d3fc65a6fb0f782d", + "expiration": 1756914801000, + "timestamp": 1756914741000 + }, + "internal_transactions": [] +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/swap-transaction.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/swap-transaction.json new file mode 100644 index 00000000..2b24bdcc --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/swap-transaction.json @@ -0,0 +1,41 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 72646600 + } + ], + "signature": [ + "a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456" + ], + "txID": "7b6589b22ca801e35fd5d98186e019757875d3fb52c0bdc38a7cf4b2f24d9808", + "net_usage": 0, + "raw_data_hex": "0a02b30a2208fb8cad07225574354080b7f5eda9335af40f081f12ef0f0a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412b90f", + "net_fee": 2209000, + "energy_usage": 0, + "blockNumber": 77641497, + "block_timestamp": 1763586027000, + "energy_fee": 70437600, + "energy_usage_total": 704376, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "14d08fca0000000000000000000000000000000000000000000000000000000000000060", + "owner_address": "412efffc7686e54ab669a1cdb1e2cc17cf4b4eca96", + "contract_address": "41f742f4589459f0923fa579600815763d1646bec3" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "b30a", + "ref_block_hash": "fb8cad0722557435", + "expiration": 1763586606000, + "fee_limit": 100000000, + "timestamp": 1763586027000 + }, + "internal_transactions": [] +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/trc10-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/trc10-transfer.json new file mode 100644 index 00000000..f81ecf1a --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/trc10-transfer.json @@ -0,0 +1,41 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 0 + } + ], + "signature": [ + "bd4f972765fbc39965bd18f802564684e1ed86750ecf02046f3a165f987b3be508df83eff42a02aa8e8bd7a5ea9cd18d3bc6a343c7d28a8807dd5c19b9ac1d3801" + ], + "txID": "24bc250718147fedde9ead0be7f17f50cfcfaf64d668ea834d5d1c69e5bf3bba", + "net_usage": 282, + "raw_data_hex": "0a02d8bd2208f0f6fce5908bbbb640c086edbdbe335a76080212720a32747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e736665724173736574436f6e7472616374123c0a07313030353131391215413482464a5ce75dce237d77c1210dc01eb9b6be151a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff420b8acb12a70c2b9e9bdbe33", + "net_fee": 0, + "energy_usage": 0, + "blockNumber": 79485136, + "block_timestamp": 1769119311000, + "energy_fee": 0, + "energy_usage_total": 0, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "amount": 88888888, + "asset_name": "1005119", + "owner_address": "413482464a5ce75dce237d77c1210dc01eb9b6be15", + "to_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4" + }, + "type_url": "type.googleapis.com/protocol.TransferAssetContract" + }, + "type": "TransferAssetContract" + } + ], + "ref_block_bytes": "d8bd", + "ref_block_hash": "f0f6fce5908bbbb6", + "expiration": 1769119368000, + "timestamp": 1769119308994 + }, + "internal_transactions": [] +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/trc20-transfer.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/trc20-transfer.json new file mode 100644 index 00000000..3b70e2bb --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-transactions/trc20-transfer.json @@ -0,0 +1,41 @@ +{ + "ret": [ + { + "contractRet": "SUCCESS", + "fee": 12987800 + } + ], + "signature": [ + "0d1a94c67125916037fe92b7f266cd595100803f5ceb21838c43f3e97a5346ee3798ee350cca88f3e04212baf984dc638a2c965fb42e31301d3556ebf04630101c" + ], + "txID": "35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa", + "net_usage": 345, + "raw_data_hex": "0a023b08220868ca0d471db52d6f40e0f3cac493335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541458437be39f3a8bfdbfee7bef93e2c5f632ceff4121541a614f803b6fd780986a42c78ec9c7f77e6ded13c2244a9059cbb0000000000000000000000002efffc7686e54ab669a1cdb1e2cc17cf4b4eca96000000000000000000000000000000000000000000000000000000000000271070809fc7c493339001c0f4a46b", + "net_fee": 0, + "energy_usage": 407, + "blockNumber": 75643657, + "block_timestamp": 1757590707000, + "energy_fee": 12987800, + "energy_usage_total": 130285, + "raw_data": { + "contract": [ + { + "parameter": { + "value": { + "data": "a9059cbb0000000000000000000000002efffc7686e54ab669a1cdb1e2cc17cf4b4eca960000000000000000000000000000000000000000000000000000000000002710", + "owner_address": "41458437be39f3a8bfdbfee7bef93e2c5f632ceff4", + "contract_address": "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" + }, + "type_url": "type.googleapis.com/protocol.TriggerSmartContract" + }, + "type": "TriggerSmartContract" + } + ], + "ref_block_bytes": "3b08", + "ref_block_hash": "68ca0d471db52d6f", + "expiration": 1757590764000, + "fee_limit": 225000000, + "timestamp": 1757590704000 + }, + "internal_transactions": [] +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-trc20-transactions/contract-info.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-trc20-transactions/contract-info.json new file mode 100644 index 00000000..2e71340f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-trc20-transactions/contract-info.json @@ -0,0 +1,51 @@ +{ + "data": [ + { + "transaction_id": "35f3dcfede12f943827809ddc18b891f78c38337e2b80912f50bd52a054497aa", + "token_info": { + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + "name": "Tether USD" + }, + "block_timestamp": 1757590707000, + "from": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "to": "TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB", + "type": "Transfer", + "value": "10000" + }, + { + "transaction_id": "beb6312754bed1de71c4087d8f0ab078be4f8b7d25b97a6fed644a1e274c51dc", + "token_info": { + "symbol": "USDDOLD", + "address": "TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn", + "decimals": 18, + "name": "Decentralized USDOLD" + }, + "block_timestamp": 1756119357000, + "from": "TMxxHG5PRVakKwNCvTWDb73gPwXvkZAhpm", + "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "type": "Transfer", + "value": "1052192663968474700" + }, + { + "transaction_id": "036fd19d19626c2be4010619a365ca8d1e15f6b615585170500c7b25f1932686", + "token_info": { + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + "name": "Tether USD" + }, + "block_timestamp": 1755862641000, + "from": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", + "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "type": "Transfer", + "value": "1067519" + } + ], + "success": true, + "meta": { + "at": 1757687703291, + "page_size": 3 + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-trc20-transactions/swap-contract-info.json b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-trc20-transactions/swap-contract-info.json new file mode 100644 index 00000000..6225496e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/mocks/trongrid/account-trc20-transactions/swap-contract-info.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "transaction_id": "7b6589b22ca801e35fd5d98186e019757875d3fb52c0bdc38a7cf4b2f24d9808", + "token_info": { + "symbol": "USDT", + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6, + "name": "Tether USD" + }, + "block_timestamp": 1763586027000, + "from": "TW7pbp4pD38qv6kSaw44i4uVzthz2bBpYc", + "to": "TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx", + "type": "Transfer", + "value": "2916449" + } + ], + "success": true, + "meta": { + "at": 1763586027000, + "page_size": 1 + } +} diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/utils/isSpam.test.ts b/merged-packages/tron-wallet-snap/src/services/transactions/utils/isSpam.test.ts new file mode 100644 index 00000000..55fabf98 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/utils/isSpam.test.ts @@ -0,0 +1,383 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { + TransactionStatus, + TransactionType, + TrxAccountType, +} from '@metamask/keyring-api'; + +import { isSpam } from './isSpam'; +import { Network, Networks } from '../../../constants'; +import type { TronKeyringAccount } from '../../../entities/keyring-account'; + +describe('isSpam', () => { + const account: TronKeyringAccount = { + id: 'account-id', + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [Network.Mainnet], + entropySource: 'test-entropy', + derivationPath: 'm/0/0', + index: 0, + }; + + const nativeAsset = (amount: string): Transaction['to'][number]['asset'] => ({ + type: Networks[Network.Mainnet].nativeToken.id, + amount, + unit: Networks[Network.Mainnet].nativeToken.symbol, + fungible: true, + }); + + const trc10Asset = (amount: string): Transaction['to'][number]['asset'] => ({ + type: `${Network.Mainnet}/trc10:1005119`, + amount, + unit: 'BTT', + fungible: true, + }); + + const trc20Asset = (amount: string): Transaction['to'][number]['asset'] => ({ + type: `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, + amount, + unit: 'USDT', + fungible: true, + }); + + const stakedForBandwidthAsset = ( + amount: string, + ): Transaction['to'][number]['asset'] => ({ + type: Networks[Network.Mainnet].stakedForBandwidth.id, + amount, + unit: Networks[Network.Mainnet].stakedForBandwidth.symbol, + fungible: true, + }); + + const transaction = (overrides: Partial = {}): Transaction => ({ + id: 'tx-id', + type: TransactionType.Receive, + account: account.id, + chain: Network.Mainnet, + status: TransactionStatus.Confirmed, + timestamp: 1, + from: [ + { + address: 'sender-address', + asset: nativeAsset('0.0005'), + }, + ], + to: [ + { + address: account.address, + asset: nativeAsset('0.0005'), + }, + ], + events: [], + fees: [], + ...overrides, + }); + + describe('Spam transactions', () => { + it('received native TRX amounts under 0.001 - for any transaction status', () => { + expect(isSpam(transaction(), account)).toBe(true); + + expect( + isSpam( + transaction({ + status: TransactionStatus.Failed, + }), + account, + ), + ).toBe(true); + }); + }); + + describe('Non-spam transactions', () => { + it('received native TRX amounts equal to 0.001 - for any transaction status', () => { + expect( + isSpam( + transaction({ + to: [{ address: account.address, asset: nativeAsset('0.001') }], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + status: TransactionStatus.Failed, + to: [{ address: account.address, asset: nativeAsset('0.001') }], + }), + account, + ), + ).toBe(false); + }); + + it('received native TRX amounts above 0.001 - for any transaction status', () => { + expect( + isSpam( + transaction({ + to: [{ address: account.address, asset: nativeAsset('0.0011') }], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + status: TransactionStatus.Failed, + to: [{ address: account.address, asset: nativeAsset('0.0011') }], + }), + account, + ), + ).toBe(false); + }); + + it('received native TRX amounts summed to 0.001 - for any transaction status', () => { + expect( + isSpam( + transaction({ + to: [ + { address: account.address, asset: nativeAsset('0.0004') }, + { address: account.address, asset: nativeAsset('0.0006') }, + ], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + status: TransactionStatus.Failed, + to: [ + { address: account.address, asset: nativeAsset('0.0004') }, + { address: account.address, asset: nativeAsset('0.0006') }, + ], + }), + account, + ), + ).toBe(false); + }); + + it('received TRC10 amounts below threshold - for any transaction status', () => { + expect( + isSpam( + transaction({ + to: [{ address: account.address, asset: trc10Asset('0.0005') }], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + status: TransactionStatus.Failed, + to: [{ address: account.address, asset: trc10Asset('0.0005') }], + }), + account, + ), + ).toBe(false); + }); + + it('received TRC20 amounts below threshold - for any transaction status', () => { + expect( + isSpam( + transaction({ + to: [{ address: account.address, asset: trc20Asset('0.0005') }], + }), + account, + ), + ).toBe(false); + expect( + isSpam( + transaction({ + status: TransactionStatus.Failed, + to: [{ address: account.address, asset: trc20Asset('0.0005') }], + }), + account, + ), + ).toBe(false); + }); + + it("any 'Send' transaction", () => { + expect( + isSpam( + transaction({ + type: TransactionType.Send, + from: [{ address: account.address, asset: nativeAsset('0.0005') }], + to: [ + { address: 'recipient-address', asset: nativeAsset('0.0005') }, + ], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + type: TransactionType.Send, + status: TransactionStatus.Failed, + from: [{ address: account.address, asset: nativeAsset('0.0005') }], + to: [ + { address: 'recipient-address', asset: nativeAsset('0.0005') }, + ], + }), + account, + ), + ).toBe(false); + }); + + it("any 'BridgeSend' transaction", () => { + expect( + isSpam( + transaction({ + type: TransactionType.BridgeSend, + from: [{ address: account.address, asset: nativeAsset('0.0005') }], + to: [{ address: 'bridge-address', asset: nativeAsset('0.0005') }], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + type: TransactionType.BridgeSend, + status: TransactionStatus.Failed, + from: [{ address: account.address, asset: nativeAsset('0.0005') }], + to: [{ address: 'bridge-address', asset: nativeAsset('0.0005') }], + }), + account, + ), + ).toBe(false); + }); + + it("any 'BridgeReceive' transaction", () => { + expect( + isSpam( + transaction({ + type: TransactionType.BridgeReceive, + to: [{ address: account.address, asset: nativeAsset('0.0005') }], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + type: TransactionType.BridgeReceive, + status: TransactionStatus.Failed, + to: [{ address: account.address, asset: nativeAsset('0.0005') }], + }), + account, + ), + ).toBe(false); + }); + + it("any 'StakingDeposit' transaction", () => { + expect( + isSpam( + transaction({ + type: TransactionType.StakeDeposit, + from: [{ address: account.address, asset: nativeAsset('0.0005') }], + to: [ + { + address: account.address, + asset: stakedForBandwidthAsset('0.0005'), + }, + ], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + type: TransactionType.StakeDeposit, + status: TransactionStatus.Failed, + from: [{ address: account.address, asset: nativeAsset('0.0005') }], + to: [ + { + address: account.address, + asset: stakedForBandwidthAsset('0.0005'), + }, + ], + }), + account, + ), + ).toBe(false); + }); + + it("any 'StakingWithdrawal' transaction", () => { + expect( + isSpam( + transaction({ + type: TransactionType.StakeWithdraw, + to: [{ address: account.address, asset: nativeAsset('0.0005') }], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + type: TransactionType.StakeWithdraw, + status: TransactionStatus.Failed, + from: [ + { + address: account.address, + asset: stakedForBandwidthAsset('0.0005'), + }, + ], + to: [{ address: account.address, asset: nativeAsset('0.0005') }], + }), + account, + ), + ).toBe(false); + }); + + it("any 'Swap' transaction", () => { + expect( + isSpam( + transaction({ + type: TransactionType.Swap, + to: [{ address: account.address, asset: trc20Asset('0.0005') }], + }), + account, + ), + ).toBe(false); + + expect( + isSpam( + transaction({ + type: TransactionType.Swap, + status: TransactionStatus.Failed, + to: [{ address: account.address, asset: trc20Asset('0.0005') }], + }), + account, + ), + ).toBe(false); + }); + + it("any 'Unknown' transaction", () => { + expect( + isSpam( + transaction({ + type: TransactionType.Unknown, + status: TransactionStatus.Failed, + from: [], + to: [], + }), + account, + ), + ).toBe(false); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/transactions/utils/isSpam.ts b/merged-packages/tron-wallet-snap/src/services/transactions/utils/isSpam.ts new file mode 100644 index 00000000..1b743e1e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/transactions/utils/isSpam.ts @@ -0,0 +1,75 @@ +import type { Transaction } from '@metamask/keyring-api'; +import { TransactionType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; + +import { KnownCaip19Id } from '../../../constants'; +import type { TronKeyringAccount } from '../../../entities/keyring-account'; + +// A function that returns true if it believes that the passed transaction is a spam, or false if it believes it's legitimate. +type SpamDetector = ( + transaction: Transaction, + account: TronKeyringAccount, +) => boolean; + +/** + * Spam Detector #1: It categorizes transactions as spam if they receive less than 0.001 TRX. + * + * @param transaction - The transaction to evaluate. + * @param account - The account associated with the transaction. + * @returns Whether the transaction passes the minimum TRX amount check (true = passes/legitimate). + */ +const isTrxAmountLowerThanThreshold: SpamDetector = ( + transaction: Transaction, + account: TronKeyringAccount, +): boolean => { + const { to, type } = transaction; + const { address } = account; + + // This checker only applies to receive transactions. + const isApplicable = type === TransactionType.Receive; + + if (!isApplicable) { + return false; + } + + const { hasReceivedTRX, receivedTRXAmount } = to.reduce( + (acc, toItem) => { + if ( + toItem.address === address && + toItem.asset?.fungible && + (toItem.asset.type === String(KnownCaip19Id.TrxMainnet) || + toItem.asset.type === String(KnownCaip19Id.TrxNile) || + toItem.asset.type === String(KnownCaip19Id.TrxShasta)) + ) { + return { + hasReceivedTRX: true, + receivedTRXAmount: acc.receivedTRXAmount.plus(toItem.asset.amount), + }; + } + + return acc; + }, + { hasReceivedTRX: false, receivedTRXAmount: new BigNumber(0) }, + ); + + return hasReceivedTRX && receivedTRXAmount.isLessThan(0.001); +}; + +/** + * Evaluates the legitimacy of a transaction based on various detectors. + * + * @param transaction - The transaction to evaluate. + * @param account - The account associated with the transaction. + * @returns True if the transaction is spam, false if it's legitimate. + */ +export function isSpam( + transaction: Transaction, + account: TronKeyringAccount, +): boolean { + const detectors: SpamDetector[] = [ + isTrxAmountLowerThanThreshold, + // Register more detectors here. + ]; + + return detectors.some((detect) => detect(transaction, account)); +} diff --git a/merged-packages/tron-wallet-snap/src/services/wallet/README.md b/merged-packages/tron-wallet-snap/src/services/wallet/README.md new file mode 100644 index 00000000..507dcb6f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/wallet/README.md @@ -0,0 +1,150 @@ +# WalletService + +The `WalletService` is responsible for handling wallet operations like signing messages and transactions for the Tron blockchain. + +## Overview + +This service implements the Tron Multichain API specification, providing methods for: + +- Signing plain text messages (`signMessage`) +- Signing serialized transactions (`signTransaction`) + +## Architecture + +The service follows a clean architecture pattern: + +1. **Request Validation**: All incoming requests are validated using Superstruct schemas +2. **Account Management**: Integrates with `AccountsService` for key derivation +3. **TronWeb Integration**: Uses `TronWebFactory` for cryptographic operations +4. **Error Handling**: Comprehensive error handling with specific error codes + +## Methods + +### `handleKeyringRequest()` + +Main entry point that routes requests to the appropriate signing method. + +**Parameters:** + +- `account`: TronKeyringAccount - The account to use for signing +- `scope`: Network - The Tron network (Mainnet, Shasta, or Nile) +- `method`: TronMultichainMethod - The method to execute +- `params`: Json - Method-specific parameters + +**Returns:** `Promise<{ signature: string }>` + +**Error Codes:** + +- `4001` - Invalid parameters +- `4002` - Invalid transaction format +- `4100` - User rejected the request +- `5000` - Unknown error + +### `signMessage()` + +Signs a plain text message using the account's private key. + +**Parameters:** + +```typescript +{ + account: TronKeyringAccount; + scope: Network; + params: { + address: string; // Tron address (Base58Check format) + message: string; // Base64-encoded message + } +} +``` + +**Returns:** `Promise<{ signature: string }>` + +**Example:** + +```typescript +const result = await walletService.signMessage({ + account: myAccount, + scope: Network.Mainnet, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: Buffer.from('Hello World').toString('base64'), + }, +}); +// result: { signature: '0xabc123...' } +``` + +### `signTransaction()` + +Signs a serialized Tron transaction. + +**Parameters:** + +```typescript +{ + account: TronKeyringAccount; + scope: Network; + params: { + scope: string; // CAIP-2 chain ID + address: string; // Tron address (Base58Check format) + transaction: string; // Base64-encoded protobuf transaction + } +} +``` + +**Returns:** `Promise<{ signature: string }>` + +**Example:** + +```typescript +const result = await walletService.signTransaction({ + account: myAccount, + scope: Network.Mainnet, + params: { + scope: 'tron:0x2b6653dc', + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: base64EncodedTransaction, + }, +}); +// result: { signature: '0xabc123...' } +``` + +## Error Handling + +The service implements comprehensive error handling: + +1. **Validation Errors** (4001): Thrown when parameters don't match expected schemas +2. **Transaction Format Errors** (4002): Thrown when transaction deserialization fails +3. **User Rejection** (4100): Thrown when user denies the signing request +4. **Unknown Errors** (5000): Catch-all for unexpected errors + +All errors are wrapped in `SnapError` with appropriate error codes for consistent error handling. + +## Testing + +The service includes comprehensive test coverage: + +- Unit tests for each method +- Error handling scenarios +- Edge cases (empty signatures, invalid formats, etc.) +- Integration with TronWeb and AccountsService + +Run tests: + +```bash +yarn test WalletService.test.ts +``` + +## Dependencies + +- `@metamask/snaps-sdk` - SnapError for error handling +- `@metamask/utils` - Json type definitions +- `AccountsService` - Key derivation +- `TronWebFactory` - TronWeb client creation +- Validation structs - Request/response validation + +## Related Files + +- `handlers/keyring.ts` - KeyringHandler that uses this service +- `handlers/keyring-types.ts` - Method and error type definitions +- `validation/structs.ts` - Validation schemas +- `services/confirmation/ConfirmationHandler.ts` - User confirmation handling diff --git a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.test.ts b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.test.ts new file mode 100644 index 00000000..3f91bab3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.test.ts @@ -0,0 +1,736 @@ +import { SnapError } from '@metamask/snaps-sdk'; +import { bytesToBase64, bytesToHex, stringToBytes } from '@metamask/utils'; +import { TronWeb } from 'tronweb'; + +import { WalletService } from './WalletService'; +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { + TronMultichainErrors, + TronMultichainMethod, +} from '../../handlers/keyring-types'; +import { mockLogger } from '../../utils/mockLogger'; +import type { AccountsService } from '../accounts/AccountsService'; +import { TransactionExpirationRefresherService } from '../transaction-expiration-refresher/TransactionExpirationRefresherService'; + +const MOCK_BLOCK_TIMESTAMP = 1_700_000_000_000; + +/** + * Builds a mock TRON block response. + * + * @param options - The block options. + * @param options.number - The block number. + * @param options.timestamp - The block timestamp. + * @param options.hashSegment - The hash segment used for ref_block_hash. + * @returns A mock TRON block response. + */ +function createBlock({ + number, + timestamp, + hashSegment = '1122334455667788', +}: { + number: number; + timestamp: number; + hashSegment?: string; +}) { + return { + blockID: `${'0'.repeat(16)}${hashSegment}${'f'.repeat(32)}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + block_header: { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: { + number, + timestamp, + }, + }, + }; +} + +const getRefBlockBytes = (number: number) => + number.toString(16).slice(-4).padStart(4, '0'); + +/** + * Helper function to convert string to base64. + * + * @param str - The string to convert. + * @returns Base64 encoded string. + */ +function toBase64(str: string): string { + return bytesToBase64(stringToBytes(str)); +} + +/** + * Helper function to convert string to hex. + * + * @param str - The string to convert. + * @returns Hex encoded string (without 0x prefix). + */ +function toHex(str: string): string { + return bytesToHex(stringToBytes(str)).slice(2); +} + +describe('WalletService', () => { + const TEST_ADDRESS = 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8'; + const ALT_OWNER_HEX = '41a614f803b6fd780986a42c78ec9c7f77e6ded13c'; + const ALT_ADDRESS = TronWeb.address.fromHex(ALT_OWNER_HEX); + const mockTronKeypair = { + privateKeyBytes: new Uint8Array(32), + publicKeyBytes: new Uint8Array(33), + privateKeyHex: 'abcd1234privatekey', + address: TEST_ADDRESS, + }; + + const mockAccount: TronKeyringAccount = { + id: '123e4567-e89b-12d3-a456-426614174000', + address: TEST_ADDRESS, + options: {}, + methods: ['signMessage', 'signTransaction'], + type: 'tron:eoa', + scopes: [Network.Mainnet, Network.Shasta], + entropySource: 'entropy-source-1' as any, + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + let walletService: WalletService; + let mockAccountsService: jest.Mocked; + let mockTronWebFactory: jest.Mocked; + let mockTronWeb: any; + + beforeEach(() => { + mockTronWeb = { + trx: { + signMessageV2: jest.fn().mockReturnValue('0xsignature123'), + getCurrentBlock: jest.fn().mockResolvedValue( + createBlock({ + number: 200_000, + timestamp: MOCK_BLOCK_TIMESTAMP, + }), + ), + getBlockByNumber: jest.fn(), + sign: jest.fn().mockResolvedValue({ + signature: ['abcd1234signature'], + }), + }, + utils: { + deserializeTx: { + deserializeTransaction: jest.fn().mockReturnValue({ + contract: [ + { + type: 'TransferContract', + parameter: { + type_url: 'type.googleapis.com/protocol.TransferContract', // eslint-disable-line @typescript-eslint/naming-convention + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: TronWeb.address.toHex(TEST_ADDRESS), + // eslint-disable-next-line @typescript-eslint/naming-convention + to_address: '41123456', + amount: 1000000, + }, + }, + }, + ], + ref_block_bytes: getRefBlockBytes(200_000), // eslint-disable-line @typescript-eslint/naming-convention + ref_block_hash: '1122334455667788', // eslint-disable-line @typescript-eslint/naming-convention + expiration: MOCK_BLOCK_TIMESTAMP + 45_000, + timestamp: MOCK_BLOCK_TIMESTAMP, + }), + }, + transaction: { + txJsonToPb: jest + .fn() + .mockImplementation((transaction) => transaction), + txPbToRawDataHex: jest.fn().mockReturnValue('refreshed-raw-data-hex'), + txPbToTxID: jest.fn().mockReturnValue('0xrefreshed-tx-id'), + }, + }, + isAddress: jest.fn().mockReturnValue(true), + }; + + mockAccountsService = { + deriveTronKeypair: jest.fn().mockResolvedValue(mockTronKeypair), + } as any; + + mockTronWebFactory = { + createClient: jest.fn().mockReturnValue(mockTronWeb), + } as any; + + walletService = new WalletService({ + logger: mockLogger, + accountsService: mockAccountsService, + tronWebFactory: mockTronWebFactory, + transactionExpirationRefresherService: { + ensureFreshMetadata: jest.fn(async ({ transaction }) => transaction), + } as unknown as TransactionExpirationRefresherService, + }); + }); + + describe('handleKeyringRequest', () => { + it('routes signMessage requests correctly', async () => { + const params = { + address: TEST_ADDRESS, + message: toBase64('Hello World'), + }; + + const result = await walletService.handleKeyringRequest({ + account: mockAccount, + scope: Network.Mainnet, + method: TronMultichainMethod.SignMessage, + params, + }); + + expect(result).toStrictEqual({ signature: '0xsignature123' }); + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + derivationPath: mockAccount.derivationPath, + }); + }); + + it('routes signTransaction requests correctly', async () => { + const params = { + address: TEST_ADDRESS, + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }; + + const result = await walletService.handleKeyringRequest({ + account: mockAccount, + scope: Network.Mainnet, + method: TronMultichainMethod.SignTransaction, + params, + }); + + expect(result).toStrictEqual({ signature: '0xabcd1234signature' }); + }); + + it('throws SnapError for unsupported methods', async () => { + await expect( + walletService.handleKeyringRequest({ + account: mockAccount, + scope: Network.Mainnet, + method: 'unsupportedMethod' as any, + params: {}, + }), + ).rejects.toThrow(SnapError); + }); + + it('handles user rejection errors', async () => { + mockTronWeb.trx.signMessageV2.mockImplementation(() => { + const error: any = new Error('User rejected'); + error.code = 4100; + throw error; + }); + + await expect( + walletService.handleKeyringRequest({ + account: mockAccount, + scope: Network.Mainnet, + method: TronMultichainMethod.SignMessage, + params: { + address: TEST_ADDRESS, + message: toBase64('Hello'), + }, + }), + ).rejects.toThrow(TronMultichainErrors.UserRejected.message); + }); + + it('handles invalid parameter errors', async () => { + await expect( + walletService.handleKeyringRequest({ + account: mockAccount, + scope: Network.Mainnet, + method: TronMultichainMethod.SignMessage, + params: { + // Missing required fields + }, + }), + ).rejects.toThrow('Expected a'); + }); + }); + + describe('signMessage', () => { + it('signs a message successfully', async () => { + const params = { + address: TEST_ADDRESS, + message: toBase64('Hello World'), + }; + + const result = await walletService.signMessage({ + account: mockAccount, + scope: Network.Mainnet, + params, + }); + + expect(result).toStrictEqual({ signature: '0xsignature123' }); + expect(mockAccountsService.deriveTronKeypair).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + derivationPath: mockAccount.derivationPath, + }); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + mockTronKeypair.privateKeyHex, + ); + expect(mockTronWeb.trx.signMessageV2).toHaveBeenCalledWith( + 'Hello World', + mockTronKeypair.privateKeyHex, + ); + }); + + it('decodes base64 message before signing', async () => { + const originalMessage = 'Test Message 123'; + const params = { + address: TEST_ADDRESS, + message: toBase64(originalMessage), + }; + + await walletService.signMessage({ + account: mockAccount, + scope: Network.Mainnet, + params, + }); + + expect(mockTronWeb.trx.signMessageV2).toHaveBeenCalledWith( + originalMessage, + expect.any(String), + ); + }); + + it('throws error for invalid params', async () => { + await expect( + walletService.signMessage({ + account: mockAccount, + scope: Network.Mainnet, + params: { + // Missing required fields + }, + }), + ).rejects.toThrow('Expected a'); + }); + }); + + describe('signTransaction', () => { + it('signs a transaction successfully', async () => { + const transactionData = 'transaction-data-bytes'; + const params = { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex(transactionData), + type: 'TransferContract', + }, + }; + + const result = await walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params, + }); + + expect(result).toStrictEqual({ signature: '0xabcd1234signature' }); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Mainnet, + mockTronKeypair.privateKeyHex, + ); + }); + + it('deserializes transaction from hex', async () => { + const params = { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('tx-data'), + type: 'TransferContract', + }, + }; + + await walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params, + }); + + expect( + mockTronWeb.utils.deserializeTx.deserializeTransaction, + ).toHaveBeenCalled(); + expect(mockTronWeb.trx.sign).toHaveBeenCalled(); + }); + + it('refreshes transaction metadata before signing stale transactions', async () => { + walletService = new WalletService({ + logger: mockLogger, + accountsService: mockAccountsService, + tronWebFactory: mockTronWebFactory, + transactionExpirationRefresherService: + new TransactionExpirationRefresherService({ + tronWebFactory: mockTronWebFactory, + }), + }); + mockTronWeb.trx.getCurrentBlock.mockResolvedValue( + createBlock({ + number: 200_000, + timestamp: MOCK_BLOCK_TIMESTAMP, + hashSegment: '0011223344556677', + }), + ); + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({ + contract: [ + { + type: 'TransferContract', + parameter: { + type_url: 'type.googleapis.com/protocol.TransferContract', // eslint-disable-line @typescript-eslint/naming-convention + value: { + owner_address: TronWeb.address.toHex(TEST_ADDRESS), // eslint-disable-line @typescript-eslint/naming-convention + to_address: '41123456', // eslint-disable-line @typescript-eslint/naming-convention + amount: 1000000, + }, + }, + }, + ], + ref_block_bytes: '0001', // eslint-disable-line @typescript-eslint/naming-convention + ref_block_hash: 'outdatedhash', // eslint-disable-line @typescript-eslint/naming-convention + expiration: MOCK_BLOCK_TIMESTAMP - 1, + timestamp: MOCK_BLOCK_TIMESTAMP - 60_000, + }); + + await walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }); + + const signedTransaction = mockTronWeb.trx.sign.mock.calls[0]?.[0]; + + expect(signedTransaction.raw_data).toStrictEqual( + expect.objectContaining({ + ref_block_bytes: getRefBlockBytes(200_000), // eslint-disable-line @typescript-eslint/naming-convention + ref_block_hash: '0011223344556677', // eslint-disable-line @typescript-eslint/naming-convention + expiration: MOCK_BLOCK_TIMESTAMP + 60_000, + timestamp: MOCK_BLOCK_TIMESTAMP, + }), + ); + expect(signedTransaction.raw_data_hex).toBe('refreshed-raw-data-hex'); + expect(signedTransaction.txID).toBe('refreshed-tx-id'); + }); + + it('handles transaction format errors', async () => { + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockImplementation( + () => { + throw new Error('Failed to deserialize transaction'); + }, + ); + + await expect( + walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('valid-but-unparseable-transaction-data'), + type: 'TransferContract', + }, + }, + }), + ).rejects.toThrow('Failed to deserialize transaction'); + }); + + it('throws error for invalid params', async () => { + await expect( + walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params: { + // Missing required fields + }, + }), + ).rejects.toThrow('Expected'); + }); + + it('prefixes signature with 0x', async () => { + mockTronWeb.trx.sign.mockResolvedValue({ + signature: ['abcdef123456'], + }); + + const result = await walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }); + + expect(result.signature).toMatch(/^0x/u); + expect(result.signature).toBe('0xabcdef123456'); + }); + + it('handles empty signature array', async () => { + mockTronWeb.trx.sign.mockResolvedValue({ + signature: [], + }); + + const result = await walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }); + + expect(result.signature).toBe('0x'); + }); + + it('rejects transactions whose owner_address does not match the signer', async () => { + mockTronWeb.utils.deserializeTx.deserializeTransaction.mockReturnValue({ + contract: [ + { + type: 'TransferContract', + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: ALT_OWNER_HEX, + // eslint-disable-next-line @typescript-eslint/naming-convention + to_address: '41123456', + amount: 1000000, + }, + }, + }, + ], + }); + + await expect( + walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params: { + address: TEST_ADDRESS, + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }), + ).rejects.toThrow( + `Transaction owner_address (${ALT_ADDRESS}) does not match derived signer address (${TEST_ADDRESS})`, + ); + }); + + it('rejects when the resolved account address does not match the derived signer', async () => { + mockAccountsService.deriveTronKeypair.mockResolvedValue({ + ...mockTronKeypair, + address: ALT_ADDRESS, + }); + + await expect( + walletService.signTransaction({ + account: mockAccount, + scope: Network.Mainnet, + params: { + address: TEST_ADDRESS, + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }), + ).rejects.toThrow( + `Transaction owner_address (${TEST_ADDRESS}) does not match derived signer address (${ALT_ADDRESS})`, + ); + }); + }); + + describe('resolveAccountAddress', () => { + const keyringAccounts = [ + { + ...mockAccount, + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + scopes: [Network.Mainnet, Network.Shasta], + }, + { + ...mockAccount, + id: '987e6543-e89b-42d3-a456-426614174999', + address: 'TGehVcNhud84JDCGrNHKVz9jEAVKUpbuiv', + scopes: [Network.Mainnet], + }, + ]; + + it('resolves valid Tron address for signMessage', async () => { + const scope = Network.Mainnet; + const address = 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8'; + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'signMessage', + params: { address, message: toBase64('test') }, + }; + + mockTronWeb.isAddress.mockReturnValue(true); + + const result = await walletService.resolveAccountAddress( + keyringAccounts, + scope, + request, + ); + + expect(result).toStrictEqual({ address: `${scope}:${address}` }); + expect(mockTronWeb.isAddress).toHaveBeenCalledWith(address); + }); + + it('resolves valid Tron address for signTransaction', async () => { + const scope = Network.Mainnet; + const address = 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8'; + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'signTransaction', + params: { + scope, + address, + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }; + + mockTronWeb.isAddress.mockReturnValue(true); + + const result = await walletService.resolveAccountAddress( + keyringAccounts, + scope, + request, + ); + + expect(result).toStrictEqual({ address: `${scope}:${address}` }); + expect(mockTronWeb.isAddress).toHaveBeenCalledWith(address); + }); + + it('throws error for invalid address', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'signMessage', + params: { address: 'invalid-address', message: toBase64('test') }, + }; + + mockTronWeb.isAddress.mockReturnValue(false); + + await expect( + walletService.resolveAccountAddress(keyringAccounts, scope, request), + ).rejects.toThrow('Invalid Tron address'); + }); + + it('throws error for missing address parameter', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'signMessage', + params: { message: toBase64('test') }, + }; + + await expect( + walletService.resolveAccountAddress(keyringAccounts, scope, request), + ).rejects.toThrow('Address parameter is required'); + }); + + it('throws error for account not in keyring', async () => { + const scope = Network.Mainnet; + const address = 'TUnknownAddressNotInKeyring1234567890'; + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'signMessage', + params: { address, message: toBase64('test') }, + }; + + mockTronWeb.isAddress.mockReturnValue(true); + + await expect( + walletService.resolveAccountAddress(keyringAccounts, scope, request), + ).rejects.toThrow('Account not found in keyring'); + }); + + it('throws error for no accounts with scope', async () => { + const scope = Network.Nile; + const address = 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8'; + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'signMessage', + params: { address, message: toBase64('test') }, + }; + + await expect( + walletService.resolveAccountAddress(keyringAccounts, scope, request), + ).rejects.toThrow('No accounts with scope'); + }); + + it('throws error for unsupported method', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'unsupportedMethod', + params: { address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8' }, + }; + + await expect( + walletService.resolveAccountAddress(keyringAccounts, scope, request), + ).rejects.toThrow('Expected one of'); + }); + + it('resolves address for different networks', async () => { + const address = 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8'; + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'signMessage', + params: { address, message: toBase64('test') }, + }; + + mockTronWeb.isAddress.mockReturnValue(true); + + const shastaResult = await walletService.resolveAccountAddress( + keyringAccounts, + Network.Shasta, + request, + ); + + expect(shastaResult).toStrictEqual({ + address: `${Network.Shasta}:${address}`, + }); + expect(mockTronWebFactory.createClient).toHaveBeenCalledWith( + Network.Shasta, + ); + }); + + it('throws error for missing method', async () => { + const scope = Network.Mainnet; + const request = { + jsonrpc: '2.0' as const, + id: 1, + params: { address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8' }, + } as any; + + await expect( + walletService.resolveAccountAddress(keyringAccounts, scope, request), + ).rejects.toThrow('Expected one of'); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts new file mode 100644 index 00000000..d2299c63 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/services/wallet/WalletService.ts @@ -0,0 +1,375 @@ +import type { ResolvedAccountAddress } from '@metamask/keyring-api'; +import { SnapError } from '@metamask/snaps-sdk'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { bytesToHex, hexToBytes, sha256 } from '@metamask/utils'; + +import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; +import type { Network } from '../../constants'; +import type { TronKeyringAccount } from '../../entities/keyring-account'; +import { + TronMultichainErrors, + TronMultichainMethod, +} from '../../handlers/keyring-types'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import { + ResolveAccountAddressRequestStruct, + ResolveAccountAddressResponseStruct, + SignMessageRequestStruct, + SignMessageResponseStruct, + SignTransactionRequestStruct, +} from '../../validation/structs'; +import { + assertTransactionSignerConsistency, + assertTransactionStructure, +} from '../../validation/transaction'; +import { validateRequest, validateResponse } from '../../validation/validators'; +import type { AccountsService } from '../accounts/AccountsService'; +import type { TransactionExpirationRefresherService } from '../transaction-expiration-refresher/TransactionExpirationRefresherService'; +/** + * Service responsible for handling wallet operations like signing messages and transactions. + */ +export class WalletService { + readonly #logger: ILogger; + + readonly #accountsService: AccountsService; + + readonly #tronWebFactory: TronWebFactory; + + readonly #transactionExpirationRefresherService: TransactionExpirationRefresherService; + + constructor({ + logger, + accountsService, + tronWebFactory, + transactionExpirationRefresherService, + }: { + logger: ILogger; + accountsService: AccountsService; + tronWebFactory: TronWebFactory; + transactionExpirationRefresherService: TransactionExpirationRefresherService; + }) { + this.#logger = createPrefixedLogger(logger, '[💼 WalletService]'); + this.#accountsService = accountsService; + this.#tronWebFactory = tronWebFactory; + this.#transactionExpirationRefresherService = + transactionExpirationRefresherService; + } + + /** + * Handles wallet requests by routing them to the appropriate method. + * + * @param request - The wallet request containing method and parameters. + * @param request.account - The account to use for signing. + * @param request.scope - The scope to use for signing. + * @param request.method - The method to execute (signMessage or signTransaction). + * @param request.params - The parameters for the method. + * @returns The result of the wallet operation. + * @throws {SnapError} If the method is not supported or if there's an error. + */ + async handleKeyringRequest({ + account, + scope, + method, + params, + }: { + account: TronKeyringAccount; + scope: Network; + method: TronMultichainMethod; + params: Json; + }): Promise<{ signature: string }> { + this.#logger.log('Handling wallet request', { + method, + accountId: account.id, + scope, + params, + }); + + try { + switch (method) { + case TronMultichainMethod.SignMessage: + return await this.signMessage({ account, scope, params }); + case TronMultichainMethod.SignTransaction: + return await this.signTransaction({ account, scope, params }); + default: + throw new SnapError( + 'Unsupported wallet method', + TronMultichainErrors.InvalidParams, + ); + } + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error handling wallet request'); + + // User rejected the request + if (error.code === 4100 || error.message?.includes('rejected')) { + throw new SnapError( + TronMultichainErrors.UserRejected.message, + TronMultichainErrors.UserRejected, + ); + } + + // Invalid parameters + if (error.code === 4001 || error.message?.includes('Invalid')) { + throw new SnapError( + error.message ?? TronMultichainErrors.InvalidParams.message, + TronMultichainErrors.InvalidParams, + ); + } + + // Unknown error + throw new SnapError( + error.message ?? TronMultichainErrors.UnknownError.message, + TronMultichainErrors.UnknownError, + ); + } + } + + /** + * Signs a plain text message with a Tron account. + * The signature can be used to verify ownership of the account. + * + * @param request - The sign message request. + * @param request.account - The account to sign with. + * @param request.scope - The scope to use for signing. + * @param request.params - The request parameters containing address and message. + * @returns An object containing the signature. + */ + async signMessage({ + account, + scope, + params, + }: { + account: TronKeyringAccount; + scope: Network; + params: Json; + }): Promise<{ signature: string }> { + try { + // Validate the params structure + validateRequest(params, SignMessageRequestStruct); + + const { address, message } = params; + + // Derive the private key for signing + const { privateKeyHex } = await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + // Create a TronWeb instance for message signing + // We can use any network since we're just signing a message + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + // Decode the base64 message to get the raw message + // eslint-disable-next-line no-restricted-globals + const decodedMessage = Buffer.from(message, 'base64').toString('utf8'); + + // Sign the message using TronWeb's signMessageV2 + const signature = tronWeb.trx.signMessageV2( + decodedMessage, + privateKeyHex, + ); + + const result = { + signature, + }; + + validateResponse(result, SignMessageResponseStruct); + + this.#logger.log('Message signed successfully', { address }); + + return result; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error signing message'); + throw error; + } + } + + /** + * Signs a Tron transaction. + * The transaction rawDataHex must be provided as a hex-encoded string. + * + * @param request - The sign transaction request. + * @param request.account - The account to sign with. + * @param request.scope - The scope to use for signing. + * @param request.params - The request parameters containing scope, address, and transaction. + * @returns An object containing the signature. + */ + async signTransaction({ + account, + scope, + params, + }: { + account: TronKeyringAccount; + scope: Network; + params: Json; + }): Promise<{ signature: string }> { + try { + // Validate the params structure + validateRequest(params, SignTransactionRequestStruct); + + const { + address, + transaction: { rawDataHex, type }, + } = params; + + // Derive the private key for signing + const { privateKeyHex, address: signerAddress } = + await this.#accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + + // Create a TronWeb instance for transaction signing + const tronWeb = this.#tronWebFactory.createClient(scope, privateKeyHex); + + // Rebuild the transaction from hex (same logic as clientRequest handler) + const rawData = tronWeb.utils.deserializeTx.deserializeTransaction( + type, + rawDataHex, + ); + assertTransactionStructure(rawData); + assertTransactionSignerConsistency(rawData, signerAddress); + + const txID = bytesToHex(await sha256(hexToBytes(rawDataHex))).slice(2); + const transaction = { + visible: false, + txID, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: rawData, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: rawDataHex, + }; + const freshTransaction = + await this.#transactionExpirationRefresherService.ensureFreshMetadata({ + scope, + transaction, + }); + + // Sign the rebuilt transaction + const signedTx = await tronWeb.trx.sign(freshTransaction, privateKeyHex); + + // Extract the signature from the signed transaction + // signedTx.signature is an array of hex strings + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const signatureArray = (signedTx as any).signature as string[]; + const signature = signatureArray?.[0] ?? ''; + + const result = { + signature: `0x${signature}`, + }; + + this.#logger.log('Transaction signed successfully', { address, scope }); + + return result; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.#logger.error({ error }, 'Error signing transaction'); + + // Check if it's a transaction format error + if ( + error.message?.includes('deserialize') || + error.message?.includes('parse') || + error.message?.includes('invalid') + ) { + throw new SnapError( + `Invalid transaction format: ${error.message}`, + TronMultichainErrors.InvalidTransaction, + ); + } + + throw error; + } + } + + /** + * Resolves the address of an account from a signing request. + * + * This is required by the routing system of MetaMask to dispatch + * incoming non-EVM dapp signing requests. + * + * @param keyringAccounts - The accounts available in the keyring. + * @param scope - Request's scope (CAIP-2 chain ID). + * @param request - Signing request object. + * @returns A Promise that resolves to the account address in CAIP-10 format + * that must be used to process this signing request. + * @throws If the request is invalid or no matching account is found. + */ + async resolveAccountAddress( + keyringAccounts: TronKeyringAccount[], + scope: Network, + request: JsonRpcRequest, + ): Promise { + this.#logger.log('Resolving account address', { + accountCount: keyringAccounts.length, + scope, + request, + }); + + // Filter accounts that support this scope + const accountsWithThisScope = keyringAccounts.filter((account) => + account.scopes.includes(scope), + ); + + if (accountsWithThisScope.length === 0) { + throw new Error(`No accounts with scope: ${scope}`); + } + + // Validate the request structure (method and params) + validateRequest(request, ResolveAccountAddressRequestStruct); + + // Extract the params from the validated request + const { params } = request; + + // Validate that address exists in params + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { address } = params as any; + if (!address || typeof address !== 'string') { + throw new Error('Address parameter is required and must be a string'); + } + + const addressToValidate = address; + + // Validate that the address is a valid Tron address + const tronWeb = this.#tronWebFactory.createClient(scope); + + const isValid = tronWeb.isAddress(addressToValidate); + if (!isValid) { + throw new Error(`Invalid Tron address: ${addressToValidate}`); + } + + // Find the account in the keyring that matches the address + const foundAccount = accountsWithThisScope.find( + (account) => account.address === addressToValidate, + ); + + if (!foundAccount) { + throw new Error( + `Account not found in keyring for address: ${addressToValidate}`, + ); + } + + // Return the address in CAIP-10 format + // CAIP-10 format: chainId:address (e.g., "tron:0x2b6653dc:TJRabPrwbZy45sbavfcjinPJC18kjpRTv8") + const caip10Address = + `${scope}:${addressToValidate}` as ResolvedAccountAddress['address']; + + // Validate the response format + validateResponse(caip10Address, ResolveAccountAddressResponseStruct); + + this.#logger.log('Address resolved successfully', { + address: addressToValidate, + caip10Address, + accountId: foundAccount.id, + }); + + return { + address: caip10Address, + }; + } +} diff --git a/merged-packages/tron-wallet-snap/src/static/tron-logo.ts b/merged-packages/tron-wallet-snap/src/static/tron-logo.ts new file mode 100644 index 00000000..b76d0e28 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/static/tron-logo.ts @@ -0,0 +1 @@ +export const TRX_IMAGE_SVG = ``; diff --git a/merged-packages/tron-wallet-snap/src/types/analytics.ts b/merged-packages/tron-wallet-snap/src/types/analytics.ts new file mode 100644 index 00000000..75ad29a8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/types/analytics.ts @@ -0,0 +1,18 @@ +/** + * Enum for transaction tracking event types. + */ +export enum TransactionEventType { + TransactionAdded = 'Transaction Added', + TransactionRejected = 'Transaction Rejected', + TransactionApproved = 'Transaction Approved', + TransactionSubmitted = 'Transaction Submitted', + TransactionFinalized = 'Transaction Finalized', +} + +/** + * Enum for security alert tracking event types. + */ +export enum SecurityEventType { + SecurityAlertDetected = 'Security Alert Detected', + SecurityScanCompleted = 'Security Scan Completed', +} diff --git a/merged-packages/tron-wallet-snap/src/types/snap.ts b/merged-packages/tron-wallet-snap/src/types/snap.ts new file mode 100644 index 00000000..be822b79 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/types/snap.ts @@ -0,0 +1,25 @@ +import type { Locale } from '../utils/i18n'; + +export type Preferences = { + locale: Locale; + currency: string; + hideBalances: boolean; + useSecurityAlerts: boolean; + useExternalPricingData: boolean; + simulateOnChainActions: boolean; + useTokenDetection: boolean; + batchCheckBalances: boolean; + displayNftMedia: boolean; + useNftDetection: boolean; +}; + +export enum FetchStatus { + Initial = 'initial', + // Loading: Before and during first fetch. + Loading = 'loading', + // Fetching: During 2nd and nth fetch. + Fetching = 'fetching', + Fetched = 'fetched', + // eslint-disable-next-line @typescript-eslint/no-shadow + Error = 'error', +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx new file mode 100644 index 00000000..de2db79b --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Asset/Asset.tsx @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ +import type { CaipAssetType, ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Box, + Icon, + Image, + Skeleton, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; + +import questionMarkSvg from '../../../../../images/question-mark.svg'; +import { KnownCaip19Id } from '../../../../constants'; +import type { Preferences } from '../../../../types/snap'; +import { formatFiat } from '../../../../utils/formatFiat'; +import { tokenToFiat } from '../../../../utils/tokenToFiat'; + +type AssetProps = { + caipId: CaipAssetType; + symbol: string; + amount: string; + iconUrl?: string; + showAmount?: boolean; + price?: number | null; + preferences?: Preferences; + priceLoading?: boolean; +}; + +/** + * Asset component for displaying assets with optional icon, amount, and price. + * Pure component with no business logic - just visual display. + * + * @param props - The props for the asset component. + * @returns The rendered asset element. + */ +export const Asset = (props: AssetProps): ComponentOrElement => { + const { caipId, symbol, amount, iconUrl, price, preferences, priceLoading } = + props; + + const fiatValue = + preferences && price + ? formatFiat( + tokenToFiat(amount, price), + preferences.currency, + preferences.locale, + ) + : ''; + + const showPriceInfo = preferences !== undefined; + const showSkeleton = showPriceInfo && priceLoading; + const showFiat = showPriceInfo && !priceLoading && fiatValue; + + const isBandwidth = + caipId === KnownCaip19Id.BandwidthMainnet || + caipId === KnownCaip19Id.BandwidthNile || + caipId === KnownCaip19Id.BandwidthShasta; + + const isEnergy = + caipId === KnownCaip19Id.EnergyMainnet || + caipId === KnownCaip19Id.EnergyNile || + caipId === KnownCaip19Id.EnergyShasta; + + const isNormalAsset = !isBandwidth && !isEnergy; + const iconSrc = iconUrl ?? questionMarkSvg; + + return ( + + {showSkeleton ? : null} + {showFiat ? {fiatValue} : null} + + {isBandwidth ? : null} + {isEnergy ? : null} + {isNormalAsset ? ( + + ) : null} + + {`${amount} ${symbol}`} + + ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx new file mode 100644 index 00000000..d409b44c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx @@ -0,0 +1,164 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Box, + Section, + Text as SnapText, + Icon, + Tooltip, + Image, + Skeleton, +} from '@metamask/snaps-sdk/jsx'; + +import type { TransactionScanEstimatedChanges } from '../../../../services/transaction-scan/types'; +import { FetchStatus, type Preferences } from '../../../../types/snap'; +import { formatAmount } from '../../../../utils/formatAmount'; +import { i18n } from '../../../../utils/i18n'; +import { isFetchStatusLoadingOrFetching } from '../../../../utils/isFetchStatusLoadingOrFetching'; + +type EstimatedChangesProps = { + changes: TransactionScanEstimatedChanges | null; + preferences: Preferences; + scanFetchStatus: FetchStatus; +}; + +const EstimatedChangesSkeleton = ({ + preferences, +}: { + preferences: Preferences; +}): ComponentOrElement => { + const translate = i18n(preferences.locale); + + return ( +
+ + + {translate('confirmation.estimatedChanges.title')} + + + + + + + + + +
+ ); +}; + +const EstimatedChangesHeader = ({ + preferences, +}: { + preferences: Preferences; +}): ComponentOrElement => { + const translate = i18n(preferences.locale); + + return ( + + + {translate('confirmation.estimatedChanges.title')} + + + + + + ); +}; + +type AssetChangeProps = { + asset: TransactionScanEstimatedChanges['assets'][0]; +}; + +const AssetChange = ({ asset }: AssetChangeProps): ComponentOrElement => { + const formattedValue = formatAmount(asset.value); + const isOut = asset.type === 'out'; + + return ( + + {asset.logo ? ( + + + + ) : null} + + {isOut ? '-' : '+'} + {formattedValue} {asset.symbol} + + + ); +}; + +export const EstimatedChanges = ({ + changes, + preferences, + scanFetchStatus, +}: EstimatedChangesProps): ComponentOrElement => { + const translate = i18n(preferences.locale); + + // Keep "refreshing skeleton" for first loading + subsequent refreshes + const isFetching = isFetchStatusLoadingOrFetching(scanFetchStatus); + const isFetched = scanFetchStatus === FetchStatus.Fetched; + const isFetchError = scanFetchStatus === FetchStatus.Error; + + if (isFetching) { + return ; + } + + // API fetch error (network failure, etc.) - show "not available" + if (isFetchError) { + return ( +
+ + + {translate('confirmation.estimatedChanges.notAvailable')} + +
+ ); + } + + const send = changes?.assets.filter((asset) => asset.type === 'out') ?? []; + const receive = changes?.assets.filter((asset) => asset.type === 'in') ?? []; + + const hasChanges = send.length > 0 || receive.length > 0; + + if (isFetched && !hasChanges) { + return ( +
+ + + {translate('confirmation.estimatedChanges.noChanges')} + +
+ ); + } + + return ( +
+ + {send?.length > 0 ? ( + + + {translate('confirmation.estimatedChanges.send')} + + + {send?.map((asset) => ( + + ))} + + + ) : null} + {receive?.length > 0 ? ( + + + {translate('confirmation.estimatedChanges.receive')} + + + {receive?.map((asset) => ( + + ))} + + + ) : null} +
+ ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx new file mode 100644 index 00000000..421500d3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/Fees.tsx @@ -0,0 +1,79 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Box, Text as SnapText } from '@metamask/snaps-sdk/jsx'; + +import { Asset } from './Asset/Asset'; +import type { SpotPrices } from '../../../clients/price-api/types'; +import type { ComputeFeeResult } from '../../../services/send/types'; +import { FetchStatus, type Preferences } from '../../../types/snap'; +import { i18n } from '../../../utils/i18n'; +import { isFetchStatusLoadingOrFetching } from '../../../utils/isFetchStatusLoadingOrFetching'; + +type FeesProps = { + fees: ComputeFeeResult; + preferences: Preferences; + tokenPrices?: SpotPrices; + tokenPricesFetchStatus?: FetchStatus; +}; + +export const Fees = ({ + fees, + preferences, + tokenPrices = {}, + tokenPricesFetchStatus = FetchStatus.Initial, +}: FeesProps): ComponentOrElement => { + const translate = i18n(preferences.locale); + const priceLoading = isFetchStatusLoadingOrFetching(tokenPricesFetchStatus); + + /** + * Make sure the TRX is shown first for cases where both + * TRX and a resource are used. + */ + const sortedFees = [...fees].sort((feeA, feeB) => { + const isTrxA = feeA.asset.unit === 'TRX'; + const isTrxB = feeB.asset.unit === 'TRX'; + + if (isTrxA && !isTrxB) return -1; + if (!isTrxA && isTrxB) return 1; + return 0; + }); + + return ( + + {sortedFees.map((feeItem, index) => { + // Get the price for this specific fee asset + const feePrice = + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tokenPrices as any)[feeItem.asset.type]?.price ?? null; + + return ( + + {/* Left side - show text only for first item (native TRX) */} + {index === 0 ? ( + + {translate('confirmation.transactionFee')} + + ) : ( + {null} + )} + + {/* Right side - fee value with asset display including price */} + + + ); + })} + + ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.test.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.test.tsx new file mode 100644 index 00000000..6c2a4e5d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.test.tsx @@ -0,0 +1,223 @@ +import { TransactionAlert } from './TransactionAlert'; +import type { TransactionAlertProps } from './TransactionAlert'; +import type { + TransactionScanError, + TransactionScanValidation, +} from '../../../../services/transaction-scan/types'; +import { FetchStatus, type Preferences } from '../../../../types/snap'; + +// Mock the getErrorMessage function +jest.mock('./getErrorMessage', () => ({ + getErrorMessage: jest.fn((error) => { + if (error.code === 'InsufficientBalance') { + return 'Insufficient balance'; + } + return 'Unknown error'; + }), +})); + +// Mock i18n +jest.mock('../../../../utils/i18n', () => ({ + i18n: (_locale: string) => (key: string, params?: any) => { + const translations: Record = { + 'confirmation.simulationTitleAPIError': + "Because of an error, we couldn't check for security alerts.", + 'confirmation.simulationMessageAPIError': + 'Only continue if you trust every address involved.', + 'confirmation.simulationErrorTitle': + 'This transaction was reverted during simulation.', + 'confirmation.simulationErrorSubtitle': params?.reason ?? '{reason}', + 'confirmation.validationErrorTitle': 'This is a deceptive request', + 'confirmation.validationErrorSubtitle': + 'If you approve this request, a third party known for scams will take all your assets.', + 'confirmation.validationErrorLearnMore': 'Learn more', + 'confirmation.validationErrorSecurityAdviced': 'Security advice by', + }; + return translations[key] ?? key; + }, +})); + +describe('TransactionAlert', () => { + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const baseProps: TransactionAlertProps = { + preferences: mockPreferences, + validation: null, + error: null, + scanFetchStatus: FetchStatus.Initial, + }; + + it('renders without crashing when fetching', () => { + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Fetching, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('renders without crashing when loading', () => { + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Loading, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('renders without crashing on API error', () => { + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Error, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('renders without crashing with no error or validation', () => { + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Fetched, + error: null, + validation: null, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('renders without crashing with simulation error', () => { + const mockError: TransactionScanError = { + type: 'validation_error', + code: 'InsufficientBalance', + message: null, + }; + + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Fetched, + error: mockError, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('renders without crashing with Malicious validation', () => { + const mockValidation: TransactionScanValidation = { + type: 'Malicious', + reason: 'known_attacker', + }; + + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Fetched, + validation: mockValidation, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('renders without crashing with Warning validation', () => { + const mockValidation: TransactionScanValidation = { + type: 'Warning', + reason: 'unfair_trade', + }; + + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Fetched, + validation: mockValidation, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('renders without crashing with Benign validation', () => { + const mockValidation: TransactionScanValidation = { + type: 'Benign', + reason: null, + }; + + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Fetched, + validation: mockValidation, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('prioritizes API error over simulation error', () => { + const mockError: TransactionScanError = { + type: 'validation_error', + code: 'InsufficientBalance', + message: null, + }; + + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Error, + error: mockError, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('prioritizes simulation error over validation error', () => { + const mockError: TransactionScanError = { + type: 'validation_error', + code: 'InsufficientBalance', + message: null, + }; + + const mockValidation: TransactionScanValidation = { + type: 'Malicious', + reason: 'known_attacker', + }; + + const props: TransactionAlertProps = { + ...baseProps, + scanFetchStatus: FetchStatus.Fetched, + error: mockError, + validation: mockValidation, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); + + it('works with different locales', () => { + const spanishPreferences: Preferences = { + ...mockPreferences, + locale: 'es', + }; + + const props: TransactionAlertProps = { + ...baseProps, + preferences: spanishPreferences, + scanFetchStatus: FetchStatus.Error, + }; + + const result = TransactionAlert(props); + expect(result).toBeDefined(); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.tsx new file mode 100644 index 00000000..0b4440c1 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.tsx @@ -0,0 +1,138 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Banner, + Box, + Icon, + Link, + Skeleton, + Text as SnapText, + type BannerProps, +} from '@metamask/snaps-sdk/jsx'; + +import { getErrorMessage } from './getErrorMessage'; +import type { + TransactionScanError, + TransactionScanValidation, +} from '../../../../services/transaction-scan/types'; +import { FetchStatus, type Preferences } from '../../../../types/snap'; +import { i18n } from '../../../../utils/i18n'; +import { isFetchStatusLoadingOrFetching } from '../../../../utils/isFetchStatusLoadingOrFetching'; + +export type TransactionAlertProps = { + preferences: Preferences; + validation: TransactionScanValidation | null; + error: TransactionScanError | null; + scanFetchStatus: FetchStatus; +}; + +const VALIDATION_TYPE_TO_SEVERITY: Partial< + Record< + NonNullable, + BannerProps['severity'] + > +> = { + Malicious: 'danger', + Warning: 'warning', +}; + +export const TransactionAlert = ({ + preferences, + validation, + error, + scanFetchStatus, +}: TransactionAlertProps): ComponentOrElement => { + const translate = i18n(preferences.locale); + + /** + * Display a loading skeleton while fetching. + * Initial loading + subsequence fetches. + */ + if (isFetchStatusLoadingOrFetching(scanFetchStatus)) { + return ( + + + + ); + } + + /** + * Displays a warning banner if the transaction scan fails. + */ + if (scanFetchStatus === FetchStatus.Error) { + return ( + + + {translate('confirmation.simulationMessageAPIError')} + + + ); + } + + /** + * Displays nothing if there is no error or validation. + */ + if (!error && !validation) { + return {null}; + } + + /** + * Displays a warning banner if the transaction scan has an error. + */ + if (error) { + return ( + + + {translate('confirmation.simulationErrorSubtitle', { + reason: getErrorMessage(error, preferences), + })} + + + ); + } + + /** + * Displays nothing if there is no validation. + */ + if (!validation) { + return {null}; + } + + const severity = validation?.type + ? VALIDATION_TYPE_TO_SEVERITY[validation.type] + : undefined; + + /** + * Displays a banner if the validation there is a validation. + */ + if (severity) { + return ( + + {translate('confirmation.validationErrorSubtitle')} + + + {translate('confirmation.validationErrorLearnMore')} + + + + {' '} + {translate('confirmation.validationErrorSecurityAdviced')}{' '} + Blockaid + + + ); + } + + /** + * Displays nothing by default. + */ + return {null}; +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/getErrorMessage.test.ts b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/getErrorMessage.test.ts new file mode 100644 index 00000000..86c6fb3f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/getErrorMessage.test.ts @@ -0,0 +1,105 @@ +import { getErrorMessage } from './getErrorMessage'; +import type { TransactionScanError } from '../../../../services/transaction-scan/types'; +import type { Preferences } from '../../../../types/snap'; + +describe('getErrorMessage', () => { + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + it('returns translated message for known error code even when message is provided', () => { + const error: TransactionScanError = { + type: 'validation_error', + code: 'GENERAL_INSUFFICIENT_FUNDS', + message: + 'The transaction provided was reverted by the node. Insufficient balances to execute the transaction', + }; + + const result = getErrorMessage(error, mockPreferences); + + // Known error codes use translated messages, not the raw message + expect(result).toBe('Insufficient funds'); + }); + + it('returns translated message for known error codes when message is null', () => { + const error: TransactionScanError = { + type: 'validation_error', + code: 'GENERAL_INSUFFICIENT_FUNDS', + message: null, + }; + + const result = getErrorMessage(error, mockPreferences); + + expect(result).toBe('Insufficient funds'); + }); + + it('returns translated message for invalid address code', () => { + const error: TransactionScanError = { + type: 'validation_error', + code: 'GENERAL_INVALID_ADDRESS', + message: null, + }; + + const result = getErrorMessage(error, mockPreferences); + + expect(result).toBe('Invalid address'); + }); + + it('returns unknown error message for unmapped code', () => { + const error: TransactionScanError = { + type: 'validation_error', + code: 'SomeUnknownCode', + message: null, + }; + + const result = getErrorMessage(error, mockPreferences); + + expect(result).toBe('An unknown error occurred'); + }); + + it('returns unknown error message when code is null', () => { + const error: TransactionScanError = { + type: 'validation_error', + code: null, + message: null, + }; + + const result = getErrorMessage(error, mockPreferences); + + expect(result).toBe('An unknown error occurred'); + }); + + it('returns unknown error message for unmapped errors', () => { + const error: TransactionScanError = { + type: 'some_error_type', + code: null, + message: null, + }; + + const result = getErrorMessage(error, mockPreferences); + + // Unknown errors return the unknownError translation + expect(result).toBe('An unknown error occurred'); + }); + + it('returns unknown error message when type and code are null', () => { + const error: TransactionScanError = { + type: null, + code: null, + message: null, + }; + + const result = getErrorMessage(error, mockPreferences); + + expect(result).toBe('An unknown error occurred'); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/getErrorMessage.ts b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/getErrorMessage.ts new file mode 100644 index 00000000..c533060d --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/getErrorMessage.ts @@ -0,0 +1,46 @@ +import type { TransactionScanError } from '../../../../services/transaction-scan/types'; +import type { Preferences } from '../../../../types/snap'; +import { i18n } from '../../../../utils/i18n'; + +/** + * Maps error codes to user-friendly messages + */ +const ERROR_MESSAGES: Record = { + // Add Tron-specific error codes here as needed + GENERAL_INSUFFICIENT_FUNDS: 'transactionScan.errors.insufficientFunds', + GENERAL_INVALID_ADDRESS: 'transactionScan.errors.invalidAddress', + UNSUPPORTED_EIP712_MESSAGE: 'transactionScan.errors.unsupportedEIP712Message', +}; + +/** + * Gets a user-friendly message from a transaction scan error. + * + * @param error - The error of the transaction scan. + * @param preferences - The user preferences containing locale information. + * @returns A user-friendly error message, or the original error code if no mapping exists. + */ +export function getErrorMessage( + error: TransactionScanError, + preferences: Preferences, +): string { + const translate = i18n(preferences.locale); + const { code, type, message } = error; + + // Try to find a translation key for the error code + const translationKey = + (code && ERROR_MESSAGES[code]) ?? 'transactionScan.errors.unknownError'; + + // Fall back to the raw message only when the error code is unknown + if (message && translationKey === 'transactionScan.errors.unknownError') { + return message; + } + + try { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return translate(translationKey as any); + } catch { + // If translation fails, return a descriptive message based on the error + return type ?? code ?? 'Unknown error'; + } +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/utils/getIconUrlForKnownAsset.ts b/merged-packages/tron-wallet-snap/src/ui/confirmation/utils/getIconUrlForKnownAsset.ts new file mode 100644 index 00000000..1b8307b0 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/utils/getIconUrlForKnownAsset.ts @@ -0,0 +1,17 @@ +import { TokenMetadata } from '../../../constants'; + +/** + * Resolves iconUrl for a given asset type. + * This should only be called with the asset types that are in TokenMetadata constants. + * + * @param assetType - The CAIP-19 asset type to resolve. + * @returns The icon URL if known, otherwise undefined. + */ +export const getIconUrlForKnownAsset = ( + assetType: string, +): string | undefined => { + if (assetType in TokenMetadata) { + return TokenMetadata[assetType as keyof typeof TokenMetadata].iconUrl; + } + return undefined; +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx new file mode 100644 index 00000000..d023b053 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx @@ -0,0 +1,110 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Section, + Text as SnapText, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; + +import { ConfirmSignMessageFormNames } from './events'; +import { Networks, type Network } from '../../../../constants'; +import type { TronKeyringAccount } from '../../../../entities/keyring-account'; +import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; +import type { Locale } from '../../../../utils/i18n'; +import { i18n } from '../../../../utils/i18n'; + +export type ConfirmSignMessageProps = { + message: string; + account: TronKeyringAccount; + scope: Network; + locale: Locale; + networkImage: string | null; + origin: string; +}; + +export const ConfirmSignMessage = ({ + message, + account, + scope, + locale, + networkImage, + origin, +}: ConfirmSignMessageProps): ComponentOrElement => { + const translate = i18n(locale); + const { address } = account; + const addressCaip10 = `${scope}:${address}` as + | `0x${string}` + | `${string}:${string}:${string}`; + + return ( + + + + {null} + + {translate('confirmation.signMessage.title')} + + {null} + + +
+ + {translate('confirmation.signMessage.message')} + + {message} +
+ +
+ {origin ? ( + + + + {translate('confirmation.origin')} + + + + + + {origin} + + ) : null} + + + {translate('confirmation.account')} + +
+ + + + {translate('confirmation.network')} + + + + {Networks[scope].name} + + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/events.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/events.tsx new file mode 100644 index 00000000..7c187d7a --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/events.tsx @@ -0,0 +1,55 @@ +import type { SnapClient } from '../../../../clients/snap/SnapClient'; + +/** + * Handles the click event for the cancel button. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param options - The options bag. + * @param options.id - The interface id. + * @returns A promise that resolves when the interface has been updated. + */ +async function onCancelButtonClick( + snapClient: SnapClient, + options: { id: string }, +): Promise { + const { id } = options; + await snapClient.resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param options - The options bag. + * @param options.id - The interface id. + * @returns A promise that resolves when the interface has been updated. + */ +async function onConfirmButtonClick( + snapClient: SnapClient, + options: { id: string }, +): Promise { + const { id } = options; + await snapClient.resolveInterface(id, true); +} + +export enum ConfirmSignMessageFormNames { + Cancel = 'confirm-sign-message-cancel', + Confirm = 'confirm-sign-message-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @param snapClient - The SnapClient instance for API interactions. + * @returns Object containing event handlers. + */ +export function createEventHandlers( + snapClient: SnapClient, +): Record Promise> { + return { + [ConfirmSignMessageFormNames.Cancel]: async (options: { id: string }) => + onCancelButtonClick(snapClient, options), + [ConfirmSignMessageFormNames.Confirm]: async (options: { id: string }) => + onConfirmButtonClick(snapClient, options), + }; +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx new file mode 100644 index 00000000..b603a6ae --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx @@ -0,0 +1,231 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import { bytesToBase64, stringToBytes } from '@metamask/utils'; + +import { render } from './render'; +import type { SnapClient } from '../../../../clients/snap/SnapClient'; +import { Network } from '../../../../constants'; +import type { TronKeyringAccount } from '../../../../entities/keyring-account'; +import { TronMultichainMethod } from '../../../../handlers/keyring-types'; +import type { Preferences } from '../../../../types/snap'; + +/** + * Helper function to convert string to base64. + * + * @param str - The string to convert. + * @returns Base64 encoded string. + */ +function toBase64(str: string): string { + return bytesToBase64(stringToBytes(str)); +} + +describe('ConfirmSignMessage render', () => { + const mockAccount: TronKeyringAccount = { + id: '123e4567-e89b-42d3-a456-426614174000', + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + options: {}, + methods: [ + TronMultichainMethod.SignMessage, + TronMultichainMethod.SignTransaction, + ], + type: 'tron:eoa', + scopes: [Network.Mainnet, Network.Shasta], + entropySource: 'entropy-source-1' as any, + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + let mockSnapClient: jest.Mocked; + + beforeEach(() => { + mockSnapClient = { + createInterface: jest.fn().mockResolvedValue('interface-id-123'), + showDialog: jest.fn().mockResolvedValue(true), + getPreferences: jest.fn().mockResolvedValue(mockPreferences), + } as any; + + // Update mocked context with our mocks + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals + const snapContext = require('../../../../context').default; + snapContext.snapClient = mockSnapClient; + }); + + it('renders the confirmation dialog with correct props', async () => { + const testOrigin = 'https://example.com'; + const testMessage = 'Hello, Tron!'; + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000001', + origin: testOrigin, + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: mockAccount.address, + message: toBase64(testMessage), + }, + }, + }; + + await render(request, mockAccount); + + // Verify createInterface was called + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + + // Verify createInterface and showDialog were called correctly + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect(mockSnapClient.showDialog).toHaveBeenCalledWith('interface-id-123'); + + // Verify the message was decoded correctly (we can't easily check the full JSX tree) + // So we verify the render function was called with correct inputs + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + }); + + it('decodes base64 message correctly', async () => { + const testMessage = 'Test message with special chars: 你好 🚀'; + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000002', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Shasta, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: mockAccount.address, + message: toBase64(testMessage), + }, + }, + }; + + await render(request, mockAccount); + + // Verify createInterface was called (message decoding happened internally) + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + }); + + it('uses fallback locale when preferences fail to load', async () => { + mockSnapClient.getPreferences.mockRejectedValue( + new Error('Failed to load'), + ); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000003', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: mockAccount.address, + message: toBase64('Test'), + }, + }, + }; + + await render(request, mockAccount); + + // Should still create interface even when preferences fail + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + }); + + it('handles missing origin gracefully', async () => { + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000004', + origin: undefined as any, + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: mockAccount.address, + message: toBase64('Test message'), + }, + }, + }; + + await render(request, mockAccount); + + // Should create interface even with missing origin (formatOrigin handles it) + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + }); + + it('uses correct network scope', async () => { + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000005', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Shasta, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: mockAccount.address, + message: toBase64('Test'), + }, + }, + }; + + await render(request, mockAccount); + + // Verify render completes successfully with Shasta scope + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + }); + + it('returns the dialog promise', async () => { + const expectedResult = true; + mockSnapClient.showDialog.mockResolvedValue(expectedResult); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000006', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: mockAccount.address, + message: toBase64('Test'), + }, + }, + }; + + const result = await render(request, mockAccount); + + expect(result).toBe(expectedResult); + }); + + it('passes TRX_IMAGE_SVG as network image', async () => { + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000007', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: mockAccount.address, + message: toBase64('Test'), + }, + }, + }; + + await render(request, mockAccount); + + // Verify interface was created with TRX image + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx new file mode 100644 index 00000000..d9d252c4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx @@ -0,0 +1,59 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import type { DialogResult } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; + +import { ConfirmSignMessage } from './ConfirmSignMessage'; +import type { Network } from '../../../../constants'; +import snapContext from '../../../../context'; +import type { TronKeyringAccount } from '../../../../entities/keyring-account'; +import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; +import { formatOrigin } from '../../../../utils/formatOrigin'; +import { FALLBACK_LANGUAGE } from '../../../../utils/i18n'; +import { SignMessageRequestStruct } from '../../../../validation/structs'; + +/** + * Renders the confirmation dialog for a sign message request. + * + * @param request - The keyring request to confirm. + * @param account - The account that the request is for. + * @returns The confirmation dialog result. + */ +export async function render( + request: KeyringRequest, + account: TronKeyringAccount, +): Promise { + assert(request.request.params, SignMessageRequestStruct); + + const { + request: { + params: { message: messageBase64 }, + }, + scope, + origin, + } = request; + + // Decode the base64 message to get the raw message + // eslint-disable-next-line no-restricted-globals + const messageUtf8 = Buffer.from(messageBase64, 'base64').toString('utf8'); + + const locale = await snapContext.snapClient + .getPreferences() + .then((preferences) => preferences.locale) + .catch(() => FALLBACK_LANGUAGE); + + const id = await snapContext.snapClient.createInterface( + , + {}, + ); + + const dialogPromise = snapContext.snapClient.showDialog(id); + + return dialogPromise; +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx new file mode 100644 index 00000000..29b460bd --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -0,0 +1,189 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Section, + Text as SnapText, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; + +import { ConfirmSignTransactionFormNames } from './events'; +import type { ConfirmSignTransactionContext } from './types'; +import { Networks } from '../../../../constants'; +import { SimulationStatus } from '../../../../services/transaction-scan/types'; +import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; +import { FetchStatus } from '../../../../types/snap'; +import { formatOrigin } from '../../../../utils/formatOrigin'; +import { i18n } from '../../../../utils/i18n'; +import { EstimatedChanges } from '../../components/EstimatedChanges/EstimatedChanges'; +import { Fees } from '../../components/Fees'; +import { TransactionAlert } from '../../components/TransactionAlert/TransactionAlert'; + +export const ConfirmSignTransaction = ({ + context, +}: { + context: ConfirmSignTransactionContext; +}): ComponentOrElement => { + const translate = i18n(context.preferences.locale); + const { + account, + scope, + origin, + networkImage, + preferences, + scan, + scanFetchStatus, + fees, + tokenPrices, + tokenPricesFetchStatus, + } = context; + + /** + * Only disable confirm button upon first load (FetchStatus.Loading) + * as opposed to subsequent loads (FetchStatus.Fetching) + */ + const shouldDisableConfirmButton = + scanFetchStatus === FetchStatus.Loading || + scan?.simulationStatus === SimulationStatus.Failed; + + const addressCaip10 = account ? `${scope}:${account.address}` : null; + + let estimatedChangesSection: ComponentOrElement | null = null; + if (preferences.simulateOnChainActions) { + if (scan?.simulationStatus === SimulationStatus.Skipped) { + estimatedChangesSection = ( +
+ + + {translate('confirmation.estimatedChanges.title')} + + + + + + + {translate('confirmation.estimatedChanges.unsupportedContract')} + +
+ ); + } else { + estimatedChangesSection = ( + + ); + } + } + + return ( + + + {/* Security Alert */} + {preferences.useSecurityAlerts ? ( + + ) : null} + + {/* Header */} + + {null} + + {translate('confirmation.signTransaction.title')} + + {null} + + + {/* Estimated Changes from Security Scan */} + {estimatedChangesSection} + + {/* Transaction Details */} +
+ {/* Request from */} + {origin ? ( + + + + {translate('confirmation.origin')} + + + + + + {formatOrigin(origin)} + + ) : null} + + {/* Account */} + + + {translate('confirmation.account')} + + {addressCaip10 ? ( +
+ ) : null} + + + {/* Network */} + + + {translate('confirmation.network')} + + + + {Networks[scope].name} + + + + {null} + + {/* Fees */} + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/events.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/events.tsx new file mode 100644 index 00000000..ea574542 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/events.tsx @@ -0,0 +1,54 @@ +import type { SnapClient } from '../../../../clients/snap/SnapClient'; + +/** + * Handle cancel button click by resolving the interface with a falsy value. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param options - The options bag. + * @param options.id - The interface id. + */ +async function onCancelButtonClick( + snapClient: SnapClient, + options: { id: string }, +): Promise { + const { id } = options; + await snapClient.resolveInterface(id, false); +} + +/** + * Handle confirm button click by resolving the interface with a truthy value. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param options - The options bag. + * @param options.id - The interface id. + */ +async function onConfirmButtonClick( + snapClient: SnapClient, + options: { id: string }, +): Promise { + const { id } = options; + await snapClient.resolveInterface(id, true); +} + +export enum ConfirmSignTransactionFormNames { + Cancel = 'confirm-sign-transaction-cancel', + Confirm = 'confirm-sign-transaction-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @param snapClient - The SnapClient instance for API interactions. + * @returns Object containing event handlers. + */ +export function createEventHandlers( + snapClient: SnapClient, +): Record Promise> { + return { + [ConfirmSignTransactionFormNames.Cancel]: async (options: { id: string }) => + onCancelButtonClick(snapClient, options), + [ConfirmSignTransactionFormNames.Confirm]: async (options: { + id: string; + }) => onConfirmButtonClick(snapClient, options), + }; +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx new file mode 100644 index 00000000..60a498a2 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx @@ -0,0 +1,395 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import { bytesToBase64, stringToBytes } from '@metamask/utils'; + +import { render } from './render'; +import type { SnapClient } from '../../../../clients/snap/SnapClient'; +import { Network } from '../../../../constants'; +import type { TronKeyringAccount } from '../../../../entities/keyring-account'; +import { TronMultichainMethod } from '../../../../handlers/keyring-types'; +import type { TransactionScanService } from '../../../../services/transaction-scan/TransactionScanService'; +import { + SimulationStatus, + type TransactionScanResult, +} from '../../../../services/transaction-scan/types'; +import type { Preferences } from '../../../../types/snap'; + +// Mock the context module +jest.mock('../../../../context', () => ({ + __esModule: true, // eslint-disable-line @typescript-eslint/naming-convention + default: { + snapClient: null, + transactionScanService: null, + state: null, + }, +})); + +/** + * Helper function to convert string to base64. + * + * @param str - The string to convert. + * @returns Base64 encoded string. + */ +function toBase64(str: string): string { + return bytesToBase64(stringToBytes(str)); +} + +describe('ConfirmSignTransaction render', () => { + const mockAccount: TronKeyringAccount = { + id: '123e4567-e89b-42d3-a456-426614174000', + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + options: {}, + methods: [ + TronMultichainMethod.SignMessage, + TronMultichainMethod.SignTransaction, + ], + type: 'tron:eoa', + scopes: [Network.Mainnet, Network.Shasta], + entropySource: 'entropy-source-1' as any, + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const mockScanResult: TransactionScanResult = { + status: 'SUCCESS', + simulationStatus: SimulationStatus.Completed, + estimatedChanges: { + assets: [ + { + type: 'out', + value: '100', + price: '0.1', + symbol: 'TRX', + name: 'Tron', + logo: null, + assetType: 'TRC20', + }, + ], + }, + validation: { + type: 'Benign', + reason: null, + }, + error: null, + }; + + let mockSnapClient: jest.Mocked; + let mockTransactionScanService: jest.Mocked; + + beforeEach(() => { + mockSnapClient = { + createInterface: jest.fn().mockResolvedValue('interface-id-123'), + showDialog: jest.fn().mockResolvedValue(true), + updateInterface: jest.fn().mockResolvedValue(undefined), + getPreferences: jest.fn().mockResolvedValue(mockPreferences), + scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), + } as any; + + mockTransactionScanService = { + scanTransaction: jest.fn().mockResolvedValue(mockScanResult), + getSecurityAlertDescription: jest.fn().mockReturnValue('description'), + } as any; + + const mockState = { + setKey: jest.fn().mockResolvedValue(undefined), + setKeyWith: jest.fn().mockResolvedValue(undefined), + getKey: jest.fn().mockResolvedValue({}), + } as any; + + const mockAssetsService = { + getAssetsByAccountId: jest.fn().mockResolvedValue([ + { rawAmount: '1000000' }, // bandwidth + { rawAmount: '50000' }, // energy + ]), + } as any; + + const mockFeeCalculatorService = { + computeFee: jest.fn().mockResolvedValue([]), + } as any; + + const mockTronWebFactory = { + createClient: jest.fn().mockReturnValue({ + utils: { + crypto: { + getTransactionID: jest.fn().mockReturnValue('mock-tx-id'), + }, + }, + }), + } as any; + + // Update mocked context with our mocks + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals + const snapContext = require('../../../../context').default; + snapContext.snapClient = mockSnapClient; + snapContext.transactionScanService = mockTransactionScanService; + snapContext.state = mockState; + snapContext.assetsService = mockAssetsService; + snapContext.feeCalculatorService = mockFeeCalculatorService; + snapContext.tronWebFactory = mockTronWebFactory; + }); + + it('renders the confirmation dialog with security scan', async () => { + const testOrigin = 'https://example.com'; + const testTransaction = toBase64('mock-transaction-data'); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000001', + origin: testOrigin, + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: mockAccount.address, + transaction: { + rawDataHex: testTransaction, + type: 'TransferContract', + }, + }, + }, + }; + + // Mock raw data with contract information + const mockRawData = { + contract: [ + { + parameter: { + value: { + // eslint-disable-next-line @typescript-eslint/naming-convention + owner_address: '41A2155E688B2BAEBDFDACD073BA79F5B22946AACF', + // eslint-disable-next-line @typescript-eslint/naming-convention + to_address: '4132F9C0C487F21716B7A8F12906B752889902655', + // eslint-disable-next-line @typescript-eslint/naming-convention + call_value: 100000, + }, + }, + }, + ], + }; + + await render(request, mockAccount, mockRawData as any); + + // Verify createInterface was called + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + + // Verify showDialog was called with the interface ID + expect(mockSnapClient.showDialog).toHaveBeenCalledWith('interface-id-123'); + + // Verify security scan was triggered with from/to addresses and value + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + accountAddress: mockAccount.address, + transactionRawData: mockRawData, + origin: testOrigin, + scope: Network.Mainnet, + options: ['simulation', 'validation'], + }), + ); + + // Verify interface was updated with scan results + expect(mockSnapClient.updateInterface).toHaveBeenCalled(); + }); + + it('handles missing transaction scan service gracefully', async () => { + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000002', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: mockAccount.address, + transaction: { + rawDataHex: toBase64('transaction'), + type: 'TransferContract', + }, + }, + }, + }; + + const mockRawData = { + contract: [], + }; + + // Set transactionScanService to null for this test + // eslint-disable-next-line no-restricted-globals, @typescript-eslint/no-require-imports + const snapContext = require('../../../../context').default; + snapContext.transactionScanService = null; + + await render(request, mockAccount, mockRawData as any); + + // Should still create interface + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + + // Should update interface without scan + expect(mockSnapClient.updateInterface).toHaveBeenCalled(); + }); + + it('handles security scan failure gracefully', async () => { + mockTransactionScanService.scanTransaction.mockRejectedValue( + new Error('Scan failed'), + ); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000003', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: mockAccount.address, + transaction: { + rawDataHex: toBase64('transaction'), + type: 'TransferContract', + }, + }, + }, + }; + + const mockRawData = { + contract: [], + }; + + await render(request, mockAccount, mockRawData as any); + + // Should still render with error state + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect(mockSnapClient.updateInterface).toHaveBeenCalled(); + }); + + it('skips security scan when preferences disable it', async () => { + mockSnapClient.getPreferences.mockResolvedValue({ + ...mockPreferences, + useSecurityAlerts: false, + simulateOnChainActions: false, + }); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000004', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: mockAccount.address, + transaction: { + rawDataHex: toBase64('transaction'), + type: 'TransferContract', + }, + }, + }, + }; + + const mockRawData = { + contract: [], + }; + + await render(request, mockAccount, mockRawData as any); + + // Security scan should not be called + expect(mockTransactionScanService.scanTransaction).not.toHaveBeenCalled(); + }); + + it('uses fallback locale when preferences fail to load', async () => { + mockSnapClient.getPreferences.mockRejectedValue( + new Error('Failed to load'), + ); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000005', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: mockAccount.address, + transaction: { + rawDataHex: toBase64('transaction'), + type: 'TransferContract', + }, + }, + }, + }; + + const mockRawData = { + contract: [], + }; + + // Should not throw, even with failed preferences + expect(await render(request, mockAccount, mockRawData as any)).toBe(true); + }); + + it('handles missing origin gracefully', async () => { + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000006', + origin: undefined as any, + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: mockAccount.address, + transaction: { + rawDataHex: toBase64('transaction'), + type: 'TransferContract', + }, + }, + }, + }; + + const mockRawData = { + contract: [], + }; + + await render(request, mockAccount, mockRawData as any); + + // Should still work + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + }); + + it('returns the dialog promise result', async () => { + const expectedResult = true; + mockSnapClient.showDialog.mockResolvedValue(expectedResult); + + const request: KeyringRequest = { + id: '00000000-0000-4000-8000-000000000007', + origin: 'https://test.com', + account: mockAccount.id, + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: mockAccount.address, + transaction: { + rawDataHex: toBase64('transaction'), + type: 'TransferContract', + }, + }, + }, + }; + + const mockRawData = { + contract: [], + }; + + const result = await render(request, mockAccount, mockRawData as any); + + expect(result).toBe(expectedResult); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx new file mode 100644 index 00000000..7b20bef4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx @@ -0,0 +1,227 @@ +import type { KeyringRequest } from '@metamask/keyring-api'; +import type { DialogResult } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import { bytesToHex, hexToBytes, sha256 } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import type { Transaction } from 'tronweb/lib/esm/types'; + +import { ConfirmSignTransaction } from './ConfirmSignTransaction'; +import { CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME } from './types'; +import type { ConfirmSignTransactionContext } from './types'; +import { Network, Networks, ZERO } from '../../../../constants'; +import snapContext from '../../../../context'; +import type { TronKeyringAccount } from '../../../../entities/keyring-account'; +import { BackgroundEventMethod } from '../../../../handlers/cronjob'; +import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; +import { FetchStatus } from '../../../../types/snap'; +import { SignTransactionRequestStruct } from '../../../../validation/structs'; +import { getIconUrlForKnownAsset } from '../../utils/getIconUrlForKnownAsset'; + +export const DEFAULT_CONTEXT: ConfirmSignTransactionContext = { + scope: Network.Mainnet, + account: null, + transaction: { + rawDataHex: '', + type: '', + }, + origin: '', + networkImage: TRX_IMAGE_SVG, + scan: null, + scanFetchStatus: FetchStatus.Initial, + tokenPrices: {}, + tokenPricesFetchStatus: FetchStatus.Initial, + fees: [], + feesFetchStatus: FetchStatus.Initial, + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }, +}; + +/** + * Renders the confirmation dialog for a sign transaction request. + * + * @param request - The keyring request to confirm. + * @param account - The account that the request is for. + * @param rawData - The raw data of the transaction. + * @returns The confirmation dialog result. + */ +export async function render( + request: KeyringRequest, + account: TronKeyringAccount, + rawData: Transaction['raw_data'], +): Promise { + const { snapClient, transactionScanService } = snapContext; + assert(request.request.params, SignTransactionRequestStruct); + + const { + request: { + params: { transaction }, + }, + scope, + origin, + } = request; + + // Build initial context + const context: ConfirmSignTransactionContext = { + ...DEFAULT_CONTEXT, + scope: scope as Network, + account, + transaction, + origin: origin ?? 'Unknown', + scanFetchStatus: FetchStatus.Loading, // Start as Loading (first fetch) not Fetching + tokenPricesFetchStatus: FetchStatus.Initial, + feesFetchStatus: FetchStatus.Initial, + }; + + const { assetsService, feeCalculatorService, priceApiClient } = snapContext; + + // Parallelize: Get preferences + Fetch account assets + const [preferences, accountAssets] = await Promise.all([ + snapClient.getPreferences().catch(() => DEFAULT_CONTEXT.preferences), + assetsService.getAssetsByAccountId(account.id, [ + Networks[scope as Network].bandwidth.id, + Networks[scope as Network].energy.id, + ]), + ]); + + context.preferences = preferences; + + const { useSecurityAlerts, simulateOnChainActions, useExternalPricingData } = + context.preferences; + + // Calculate fees + try { + const [bandwidthAsset, energyAsset] = accountAssets; + + const availableEnergy = energyAsset + ? new BigNumber(energyAsset.rawAmount) + : ZERO; + const availableBandwidth = bandwidthAsset + ? new BigNumber(bandwidthAsset.rawAmount) + : ZERO; + + // Build transaction object from raw data + const txID = bytesToHex( + await sha256(hexToBytes(transaction.rawDataHex)), + ).slice(2); + + const transactionObj: Transaction = { + visible: true, + txID, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data: rawData, + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: transaction.rawDataHex, + }; + + const fees = await feeCalculatorService.computeFee({ + scope: scope as Network, + transaction: transactionObj, + availableEnergy, + availableBandwidth, + }); + + // Resolve icon URLs for fee assets + fees.forEach((fee) => { + fee.asset.iconUrl = getIconUrlForKnownAsset(fee.asset.type); + }); + + // Fetch prices if enabled + const tokenPrices = useExternalPricingData + ? await priceApiClient + .getMultipleSpotPrices( + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [`${scope}/slip44:195`] as any, + context.preferences.currency, + ) + .catch(() => ({})) + : {}; + + context.fees = fees; + context.feesFetchStatus = FetchStatus.Fetched; + context.tokenPrices = tokenPrices; + context.tokenPricesFetchStatus = FetchStatus.Fetched; + } catch { + context.fees = []; + context.feesFetchStatus = FetchStatus.Error; + context.tokenPrices = {}; + context.tokenPricesFetchStatus = FetchStatus.Error; + } + + // Create initial interface with loading state + const id = await snapClient.createInterface( + , + context, + ); + + const dialogPromise = snapClient.showDialog(id); + + // Parallelize: Store interface ID + Start security scan + const storeIdPromise = snapContext.state.setKey( + `mapInterfaceNameToId.${CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME}`, + id, + ); + + if (transactionScanService && (useSecurityAlerts || simulateOnChainActions)) { + const options: string[] = []; + + if (simulateOnChainActions) { + options.push('simulation'); + } + + if (useSecurityAlerts) { + options.push('validation'); + } + + try { + const scan = await transactionScanService.scanTransaction({ + accountAddress: account.address, + transactionRawData: rawData, + origin, + scope: scope as Network, + options, + }); + + context.scan = scan; + context.scanFetchStatus = scan ? FetchStatus.Fetched : FetchStatus.Error; + } catch { + context.scan = null; + context.scanFetchStatus = FetchStatus.Error; + } + + await storeIdPromise; + + await snapClient.updateInterface( + id, + , + context, + ); + + await snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshSignTransaction, + duration: 'PT20S', + }); + } else { + context.scanFetchStatus = FetchStatus.Fetched; + + await storeIdPromise; + + await snapClient.updateInterface( + id, + , + context, + ); + } + + return dialogPromise; +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/types.ts b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/types.ts new file mode 100644 index 00000000..66dbee41 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/types.ts @@ -0,0 +1,26 @@ +import type { SpotPrices } from '../../../../clients/price-api/types'; +import type { Network } from '../../../../constants'; +import type { TronKeyringAccount } from '../../../../entities/keyring-account'; +import type { ComputeFeeResult } from '../../../../services/send/types'; +import type { TransactionScanResult } from '../../../../services/transaction-scan/types'; +import type { FetchStatus, Preferences } from '../../../../types/snap'; + +export const CONFIRM_SIGN_TRANSACTION_INTERFACE_NAME = 'confirmSignTransaction'; + +export type ConfirmSignTransactionContext = { + scope: Network; + account: TronKeyringAccount | null; + transaction: { + rawDataHex: string; + type: string; + }; + origin: string; + preferences: Preferences; + networkImage: string; + scan: TransactionScanResult | null; + scanFetchStatus: FetchStatus; + tokenPrices: SpotPrices; + tokenPricesFetchStatus: FetchStatus; + fees: ComputeFeeResult; + feesFetchStatus: FetchStatus; +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx new file mode 100644 index 00000000..bf1d44d8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx @@ -0,0 +1,222 @@ +import { ConfirmTransactionRequest } from './ConfirmTransactionRequest'; +import type { ConfirmTransactionRequestContext } from './types'; +import { Network } from '../../../../constants'; +import { + SimulationStatus, + type TransactionScanResult, +} from '../../../../services/transaction-scan/types'; +import { FetchStatus, type Preferences } from '../../../../types/snap'; + +// Mock i18n +jest.mock('../../../../utils/i18n', () => ({ + i18n: (_locale: string) => (key: string) => key, +})); + +// Mock getExplorerUrl +jest.mock('../../../../utils/getExplorerUrl', () => ({ + getExplorerUrl: (_scope: string, _type: string, _address: string) => + 'https://explorer.example.com', +})); + +describe('ConfirmTransactionRequest', () => { + const mockPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + }; + + const mockScanResult: TransactionScanResult = { + status: 'SUCCESS', + simulationStatus: SimulationStatus.Completed, + estimatedChanges: { + assets: [ + { + type: 'out', + value: '1', + price: '0.1', + symbol: 'TRX', + name: 'Tron', + logo: null, + assetType: 'TRC20', + }, + ], + }, + validation: { + type: 'Benign', + reason: null, + }, + error: null, + }; + + const baseContext: ConfirmTransactionRequestContext = { + origin: 'MetaMask', + scope: Network.Mainnet, + fromAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + toAddress: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + amount: '1', + fees: [], + asset: { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: 'account-1', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '10000000', + uiAmount: '10', + iconUrl: '', + }, + preferences: mockPreferences, + networkImage: '', + tokenPrices: {}, + tokenPricesFetchStatus: FetchStatus.Fetched, + scan: mockScanResult, + scanFetchStatus: FetchStatus.Fetched, + transactionRawData: null, + accountType: 'tron:eoa', + }; + + it('renders without crashing with security scan data', () => { + const result = ConfirmTransactionRequest({ context: baseContext }); + expect(result).toBeDefined(); + }); + + it('renders when useSecurityAlerts is true and scan data exists', () => { + const result = ConfirmTransactionRequest({ context: baseContext }); + expect(result).toBeDefined(); + // The component renders successfully with security alerts enabled and scan data present + expect(JSON.stringify(result)).toContain( + 'confirm-sign-and-send-transaction-confirm', + ); + }); + + it('renders without TransactionAlert when useSecurityAlerts is false', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + preferences: { + ...mockPreferences, + useSecurityAlerts: false, + }, + }; + + const result = ConfirmTransactionRequest({ context }); + expect(result).toBeDefined(); + }); + + it('renders EstimatedChanges when simulateOnChainActions is true', () => { + const result = ConfirmTransactionRequest({ context: baseContext }); + expect(result).toBeDefined(); + expect(JSON.stringify(result)).toContain( + 'confirmation.estimatedChanges.title', + ); + }); + + it('hides EstimatedChanges when simulateOnChainActions is false', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + preferences: { + ...mockPreferences, + simulateOnChainActions: false, + }, + }; + + const result = ConfirmTransactionRequest({ context }); + expect(result).toBeDefined(); + expect(JSON.stringify(result)).not.toContain( + 'confirmation.estimatedChanges.title', + ); + }); + + it('disables confirm button when scanFetchStatus is loading', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scanFetchStatus: FetchStatus.Loading, + scan: null, + }; + + const result = ConfirmTransactionRequest({ context }); + const serialized = JSON.stringify(result); + + // The confirm button should have disabled=true + expect(serialized).toContain('"disabled":true'); + }); + + it('disables confirm button when scan status is ERROR', () => { + const errorScanResult: TransactionScanResult = { + ...mockScanResult, + status: 'ERROR', + simulationStatus: SimulationStatus.Failed, + }; + + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scan: errorScanResult, + scanFetchStatus: FetchStatus.Fetched, + }; + + const result = ConfirmTransactionRequest({ context }); + const serialized = JSON.stringify(result); + + // The confirm button should have disabled=true + expect(serialized).toContain('"disabled":true'); + }); + + it('enables confirm button when scan is successful', () => { + const result = ConfirmTransactionRequest({ context: baseContext }); + const serialized = JSON.stringify(result); + + // The confirm button should NOT have disabled=true + // When disabled is false/undefined, it shouldn't be in the serialized output + // or it should be false + expect(serialized).not.toContain('"disabled":true'); + }); + + it('enables confirm button when scanFetchStatus is fetching', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scanFetchStatus: FetchStatus.Fetching, + scan: null, + }; + + const result = ConfirmTransactionRequest({ context }); + const serialized = JSON.stringify(result); + + // The confirm button should not have disabled=true + expect(serialized).not.toContain('"disabled":true'); + }); + + it('renders with scan error state', () => { + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scan: null, + scanFetchStatus: FetchStatus.Error, + }; + + const result = ConfirmTransactionRequest({ context }); + expect(result).toBeDefined(); + }); + + it('renders with Malicious validation', () => { + const maliciousScanResult: TransactionScanResult = { + ...mockScanResult, + validation: { + type: 'Malicious', + reason: 'known_attacker', + }, + }; + + const context: ConfirmTransactionRequestContext = { + ...baseContext, + scan: maliciousScanResult, + }; + + const result = ConfirmTransactionRequest({ context }); + expect(result).toBeDefined(); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx new file mode 100644 index 00000000..2d1915e4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.tsx @@ -0,0 +1,199 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Link, + Section, + Text as SnapText, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; + +import { ConfirmSignAndSendTransactionFormNames } from './events'; +import { type ConfirmTransactionRequestContext } from './types'; +import { Networks } from '../../../../constants'; +import { SimulationStatus } from '../../../../services/transaction-scan/types'; +import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; +import { FetchStatus } from '../../../../types/snap'; +import { getExplorerUrl } from '../../../../utils/getExplorerUrl'; +import { i18n } from '../../../../utils/i18n'; +import { EstimatedChanges } from '../../components/EstimatedChanges/EstimatedChanges'; +import { Fees } from '../../components/Fees'; +import { TransactionAlert } from '../../components/TransactionAlert/TransactionAlert'; + +export const ConfirmTransactionRequest = ({ + context: { + origin, + scope, + fromAddress, + toAddress, + fees, + preferences, + networkImage, + tokenPrices, + tokenPricesFetchStatus, + scan, + scanFetchStatus, + }, +}: { + context: ConfirmTransactionRequestContext; +}): ComponentOrElement => { + const translate = i18n(preferences.locale); + + /** + * Only disable confirm button upon first load (FetchStatus.Loading) + * as opposed to subsequent loads (FetchStatus.Fetching) + */ + const shouldDisableConfirmButton = + scanFetchStatus === FetchStatus.Loading || + scan?.simulationStatus === SimulationStatus.Failed; + + let estimatedChangesSection: ComponentOrElement | null = null; + if (preferences.simulateOnChainActions) { + if (scan?.simulationStatus === SimulationStatus.Skipped) { + estimatedChangesSection = ( +
+ + + {translate('confirmation.estimatedChanges.title')} + + + + + + + {translate('confirmation.estimatedChanges.unsupportedContract')} + +
+ ); + } else { + estimatedChangesSection = ( + + ); + } + } + + return ( + + + {/* Security Alert */} + {preferences.useSecurityAlerts ? ( + + ) : null} + + {/* Header */} + + {null} + + {translate(`confirmation.transaction.title`)} + + {null} + + + {/* Estimated Changes (from security scan simulation) */} + {estimatedChangesSection} + + {/* Additional Details */} +
+ {/* Request from */} + + + + {translate('confirmation.origin')} + + + + + + {origin} + + {null} + {/* From */} + + + {translate('confirmation.from')} + + +
+ + + {null} + {/* To */} + {toAddress && ( + + + {translate('confirmation.to')} + + +
+ + + )} + {null} + {/* Network */} + + + {translate('confirmation.network')} + + + + + + {Networks[scope].name} + + + {null} + {/* Fee Breakdown */} + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/events.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/events.tsx new file mode 100644 index 00000000..1ffcf838 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/events.tsx @@ -0,0 +1,55 @@ +import type { SnapClient } from '../../../../clients/snap/SnapClient'; + +/** + * Handle cancel button click by resolving the interface with a falsy value. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param options - The options bag. + * @param options.id - The interface id. + */ +async function onCancelButtonClick( + snapClient: SnapClient, + options: { id: string }, +): Promise { + const { id } = options; + await snapClient.resolveInterface(id, false); +} + +/** + * Handle confirm button click by resolving the interface with a truthy value. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param options - The options bag. + * @param options.id - The interface id. + */ +async function onConfirmButtonClick( + snapClient: SnapClient, + options: { id: string }, +): Promise { + const { id } = options; + await snapClient.resolveInterface(id, true); +} + +export enum ConfirmSignAndSendTransactionFormNames { + Cancel = 'confirm-sign-and-send-transaction-cancel', + Confirm = 'confirm-sign-and-send-transaction-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @param snapClient - The SnapClient instance for API interactions. + * @returns Object containing event handlers. + */ +export function createEventHandlers( + snapClient: SnapClient, +): Record Promise> { + return { + [ConfirmSignAndSendTransactionFormNames.Cancel]: async (options: { + id: string; + }) => onCancelButtonClick(snapClient, options), + [ConfirmSignAndSendTransactionFormNames.Confirm]: async (options: { + id: string; + }) => onConfirmButtonClick(snapClient, options), + }; +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.test.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.test.tsx new file mode 100644 index 00000000..56d9321b --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.test.tsx @@ -0,0 +1,447 @@ +import { Types } from 'tronweb'; + +import { render } from './render'; +import { + buildTransactionRawData, + extractScanParametersFromTransactionData, +} from '../../../../clients/security-alerts-api/utils'; +import type { SnapClient } from '../../../../clients/snap/SnapClient'; +import { Network } from '../../../../constants'; +import type { AssetEntity } from '../../../../entities/assets'; +import { BackgroundEventMethod } from '../../../../handlers/cronjob'; +import type { + State, + UnencryptedStateValue, +} from '../../../../services/state/State'; +import type { TransactionScanService } from '../../../../services/transaction-scan/TransactionScanService'; +import { + SimulationStatus, + type TransactionScanResult, +} from '../../../../services/transaction-scan/types'; +import { FetchStatus, type Preferences } from '../../../../types/snap'; + +// Mock the context module +jest.mock('../../../../context', () => ({ + __esModule: true, // eslint-disable-line @typescript-eslint/naming-convention + default: { + transactionScanService: null, + }, +})); + +// --------------------------------------------------------------------------- +// Mock types +// --------------------------------------------------------------------------- + +/** + * Subset of SnapClient methods exercised by `render`. + */ +type MockSnapClient = jest.Mocked< + Pick< + SnapClient, + | 'createInterface' + | 'showDialog' + | 'updateInterface' + | 'getPreferences' + | 'scheduleBackgroundEvent' + > +>; + +/** + * Subset of TransactionScanService methods exercised by `render`. + */ +type MockTransactionScanService = jest.Mocked< + Pick< + TransactionScanService, + 'scanTransaction' | 'getSecurityAlertDescription' + > +>; + +/** + * Subset of State methods exercised by `render`. + */ +type MockState = { + getKey: jest.Mock; + setKey: jest.Mock; + setKeyWith: jest.Mock; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const defaultPreferences: Preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, +}; + +const mockAsset: AssetEntity = { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: 'account-1', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '10000000', + uiAmount: '10', + iconUrl: '', +}; + +const defaultScanResult: TransactionScanResult = { + status: 'SUCCESS', + simulationStatus: SimulationStatus.Completed, + estimatedChanges: { + assets: [ + { + type: 'out', + value: '1000000', + price: '0.1', + symbol: 'TRX', + name: 'Tron', + logo: null, + assetType: 'TRC20', + }, + ], + }, + validation: { + type: 'Benign', + reason: null, + }, + error: null, +}; + +const defaultTransactionRawData = buildTransactionRawData({ + from: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + to: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + amount: 1000000, + contractType: Types.ContractType.TransferContract, +}); + +const defaultIncomingContext = { + scope: Network.Mainnet, + fromAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + toAddress: 'TQkE4s6hQqxym4fYvtVLNEGPsaAChFqxPk', + amount: '1', + fees: [] as never[], + asset: mockAsset, + origin: 'MetaMask', + accountType: 'tron:eoa', + transactionRawData: defaultTransactionRawData, +}; + +// --------------------------------------------------------------------------- +// Builder functions +// --------------------------------------------------------------------------- + +/** + * Builds a mock SnapClient with only the methods exercised by the `render` + * flow. + * + * @returns A mock SnapClient. + */ +function buildMockSnapClient(): MockSnapClient { + return { + createInterface: jest.fn().mockResolvedValue('interface-id-123'), + showDialog: jest.fn().mockResolvedValue(true), + updateInterface: jest.fn().mockResolvedValue(undefined), + getPreferences: jest.fn().mockResolvedValue(defaultPreferences), + scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), + }; +} + +/** + * Builds a mock TransactionScanService with only the methods exercised by + * the `render` flow. + * + * @returns A mock TransactionScanService. + */ +function buildMockTransactionScanService(): MockTransactionScanService { + return { + scanTransaction: jest.fn().mockResolvedValue(defaultScanResult), + getSecurityAlertDescription: jest.fn().mockReturnValue('description'), + }; +} + +/** + * Builds a mock State with only the methods exercised by the `render` flow. + * + * @returns A mock State. + */ +function buildMockState(): MockState { + return { + setKey: jest.fn().mockResolvedValue(undefined), + setKeyWith: jest.fn().mockResolvedValue(undefined), + getKey: jest.fn().mockResolvedValue({}), + }; +} + +// --------------------------------------------------------------------------- +// Factory function +// --------------------------------------------------------------------------- + +/** + * The callback that `withRender` calls. + */ +type WithRenderCallback = (payload: { + mockSnapClient: MockSnapClient; + mockState: MockState; + mockTransactionScanService: MockTransactionScanService; + callRender: ( + contextOverrides?: Partial[2]>, + ) => ReturnType; +}) => Promise | void; + +/** + * Options for the `withRender` factory function. + */ +type WithRenderOptions = { + hasTransactionScanService?: boolean; +}; + +/** + * Constructs render mocks with sensible defaults and calls the given test + * function with helpers and all mocks. The `callRender` helper concentrates + * the type assertions so that every test body stays assertion-free. + * + * @param args - Either a callback, or an options bag + callback. + */ +async function withRender( + ...args: [WithRenderCallback] | [WithRenderOptions, WithRenderCallback] +): Promise { + const [options, testFunction] = args.length === 2 ? args : [{}, args[0]]; + const { hasTransactionScanService = true } = options; + + const mockSnapClient = buildMockSnapClient(); + const mockState = buildMockState(); + const mockTransactionScanService = buildMockTransactionScanService(); + + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals + const snapContext = require('../../../../context').default; + snapContext.transactionScanService = hasTransactionScanService + ? mockTransactionScanService + : null; + + const callRender = async ( + contextOverrides?: Partial[2]>, + ) => + render( + mockSnapClient as unknown as SnapClient, + mockState as unknown as State, + { ...defaultIncomingContext, ...contextOverrides }, + ); + + await testFunction({ + mockSnapClient, + mockState, + mockTransactionScanService, + callRender, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ConfirmTransactionRequest render', () => { + it('triggers security scan when preferences enable it', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + await callRender(); + + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect(mockSnapClient.showDialog).toHaveBeenCalledWith( + 'interface-id-123', + ); + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + accountAddress: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transactionRawData: expect.any(Object), + origin: 'MetaMask', + scope: Network.Mainnet, + options: ['simulation', 'validation'], + }), + ); + expect(mockSnapClient.updateInterface).toHaveBeenCalled(); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + }, + ); + }); + + it('always triggers scan even when security preferences are disabled', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockSnapClient.getPreferences.mockResolvedValue({ + ...defaultPreferences, + useSecurityAlerts: false, + simulateOnChainActions: false, + }); + + await callRender(); + + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + options: ['simulation'], + }), + ); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + }, + ); + }); + + it('handles security scan failure gracefully', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockTransactionScanService.scanTransaction.mockRejectedValue( + new Error('Scan failed'), + ); + + await callRender(); + + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect(mockSnapClient.updateInterface).toHaveBeenCalled(); + + const updateCall = mockSnapClient.updateInterface.mock.calls[0]; + const contextArg = updateCall?.[2] as any; + expect(contextArg?.scanFetchStatus).toBe(FetchStatus.Error); + expect(contextArg?.scan).toBeNull(); + }, + ); + }); + + it('handles missing transaction scan service gracefully', async () => { + await withRender( + { hasTransactionScanService: false }, + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + await callRender(); + + expect(mockSnapClient.createInterface).toHaveBeenCalledTimes(1); + expect( + mockTransactionScanService.scanTransaction, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('uses fallback preferences when preferences fail to load', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockSnapClient.getPreferences.mockRejectedValue( + new Error('Failed to load'), + ); + + const result = await callRender(); + + expect(result).toBe(true); + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalled(); + }, + ); + }); + + it('requests only simulation when useSecurityAlerts is false', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockSnapClient.getPreferences.mockResolvedValue({ + ...defaultPreferences, + useSecurityAlerts: false, + simulateOnChainActions: true, + }); + + await callRender(); + + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + options: ['simulation'], + }), + ); + }, + ); + }); + + it('includes both simulation and validation when useSecurityAlerts is true', async () => { + await withRender( + async ({ callRender, mockSnapClient, mockTransactionScanService }) => { + mockSnapClient.getPreferences.mockResolvedValue({ + ...defaultPreferences, + useSecurityAlerts: true, + simulateOnChainActions: false, + }); + + await callRender(); + + expect(mockTransactionScanService.scanTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + options: ['simulation', 'validation'], + }), + ); + }, + ); + }); + + it('forwards TRC20 transaction raw data to the scan service', async () => { + await withRender(async ({ callRender, mockTransactionScanService }) => { + const trc20RawData = buildTransactionRawData({ + from: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + to: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + amount: 0, + data: 'a9059cbb0000000000000000000000004f32e5a36a5e1fc1c5c327a522cdfbc78e400f5500000000000000000000000000000000000000000000000000000000000f4240', + contractType: Types.ContractType.TriggerSmartContract, + }); + + await callRender({ transactionRawData: trc20RawData }); + + const callArgs = mockTransactionScanService.scanTransaction.mock + .calls[0]?.[0] as any; + const rawData = callArgs?.transactionRawData; + + expect(rawData?.contract[0]?.type).toBe('TriggerSmartContract'); + + const scanParams = extractScanParametersFromTransactionData(rawData); + expect(scanParams?.data).toBeDefined(); + expect(scanParams?.data).toMatch(/^0xa9059cbb/u); + }); + }); + + it('schedules price refresh when pricing data is enabled', async () => { + await withRender(async ({ callRender, mockSnapClient }) => { + await callRender(); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationPrices, + duration: 'PT1S', + }); + }); + }); + + it('returns the dialog promise result', async () => { + await withRender(async ({ callRender, mockSnapClient }) => { + mockSnapClient.showDialog.mockResolvedValue(true); + + const result = await callRender(); + + expect(result).toBe(true); + }); + }); + + it('stores interface ID in state for background refresh', async () => { + await withRender(async ({ callRender, mockState }) => { + await callRender(); + + expect(mockState.setKey).toHaveBeenCalledWith( + 'mapInterfaceNameToId.confirmTransaction', + 'interface-id-123', + ); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx new file mode 100644 index 00000000..5ae92e68 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/render.tsx @@ -0,0 +1,207 @@ +import type { DialogResult, Json } from '@metamask/snaps-sdk'; +import type { Types } from 'tronweb'; + +import { ConfirmTransactionRequest } from './ConfirmTransactionRequest'; +import { + CONFIRM_TRANSACTION_INTERFACE_NAME, + type ConfirmTransactionRequestContext, +} from './types'; +import type { SnapClient } from '../../../../clients/snap/SnapClient'; +import { Network } from '../../../../constants'; +import snapContext from '../../../../context'; +import type { AssetEntity } from '../../../../entities/assets'; +import type { TronKeyringAccount } from '../../../../entities/keyring-account'; +import { BackgroundEventMethod } from '../../../../handlers/cronjob'; +import type { ComputeFeeResult } from '../../../../services/send/types'; +import type { + State, + UnencryptedStateValue, +} from '../../../../services/state/State'; +import { TRX_IMAGE_SVG } from '../../../../static/tron-logo'; +import { FetchStatus } from '../../../../types/snap'; +import { getIconUrlForKnownAsset } from '../../utils/getIconUrlForKnownAsset'; + +export const DEFAULT_CONFIRMATION_CONTEXT: ConfirmTransactionRequestContext = { + scope: Network.Mainnet, + fromAddress: null, + toAddress: null, + amount: null, + fees: [], + asset: { + assetType: `${Network.Mainnet}/slip44:195`, + keyringAccountId: '', + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + origin: 'MetaMask', + networkImage: TRX_IMAGE_SVG, + tokenPrices: {}, + tokenPricesFetchStatus: FetchStatus.Initial, + scan: null, + scanFetchStatus: FetchStatus.Initial, + transactionRawData: null, + accountType: '', + preferences: { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: false, + useNftDetection: false, + }, +}; + +/** + * Render the ConfirmTransactionRequest UI and show a dialog resolving to the user's choice. + * + * @param snapClient - The SnapClient instance for API interactions. + * @param state - The state manager instance. + * @param incomingContext - The initial context for the confirmation view. + * @param incomingContext.scope - The network scope for the transaction. + * @param incomingContext.fromAddress - The sender address. + * @param incomingContext.toAddress - The recipient address. + * @param incomingContext.amount - The amount to send (as a string). + * @param incomingContext.fees - The detailed fee breakdown array. + * @param incomingContext.asset - The asset involved in the transaction. + * @param incomingContext.origin - The origin string to display. + * @param incomingContext.accountType - The account type for analytics. + * @param incomingContext.transactionRawData - The raw transaction data for security scanning. + * @returns A dialog result with the user's decision. + */ +export async function render( + snapClient: SnapClient, + state: State, + incomingContext: { + scope: Network; + fromAddress: string; + toAddress: string; + amount: string; + fees: ComputeFeeResult; + asset: AssetEntity; + origin: string; + accountType: string; + transactionRawData: Types.Transaction['raw_data']; + }, +): Promise { + const { transactionScanService } = snapContext; + + const { transactionRawData } = incomingContext; + + // 1. Initial context with loading state + const context: ConfirmTransactionRequestContext = { + ...DEFAULT_CONFIRMATION_CONTEXT, + ...incomingContext, + transactionRawData: transactionRawData as unknown as Json, + tokenPricesFetchStatus: FetchStatus.Loading, // Start as Loading (first fetch) not Fetching + scanFetchStatus: FetchStatus.Loading, // Start as Loading (first fetch) not Fetching + }; + + try { + context.preferences = await snapClient.getPreferences(); + } catch { + // keep defaults + } + + const { useSecurityAlerts } = context.preferences; + + /** + * Resolve icon URLs for fee assets from known asset metadata. + */ + context.fees.forEach((fee) => { + fee.asset.iconUrl = getIconUrlForKnownAsset(fee.asset.type); + }); + + // 2. Initial render with loading skeleton (always show loading if pricing enabled) + const id = await snapClient.createInterface( + , + context, + ); + const dialogPromise = snapClient.showDialog(id); + + // Store interface ID by name for background refresh (Solana pattern) + const storeIdPromise = state.setKey( + `mapInterfaceNameToId.${CONFIRM_TRANSACTION_INTERFACE_NAME}`, + id, + ); + + // 3. Perform security scan (always needed for estimated changes simulation) + if (transactionScanService) { + // Always request simulation for estimated changes; + // conditionally add validation based on user preference + const options: string[] = ['simulation']; + + if (useSecurityAlerts) { + options.push('validation'); + } + + // Create a minimal account object for analytics tracking + const scanAccount = { + type: incomingContext.accountType, + address: incomingContext.fromAddress, + } as TronKeyringAccount; + + try { + const scan = await transactionScanService.scanTransaction({ + accountAddress: incomingContext.fromAddress, + transactionRawData, + origin: incomingContext.origin, + scope: incomingContext.scope, + options, + account: scanAccount, + }); + + context.scan = scan; + context.scanFetchStatus = scan ? FetchStatus.Fetched : FetchStatus.Error; + } catch { + context.scan = null; + context.scanFetchStatus = FetchStatus.Error; + } + } else { + // No scan service available, mark as fetched immediately + context.scanFetchStatus = FetchStatus.Fetched; + } + + // Ensure interface ID is stored before updating + await storeIdPromise; + + // 4. If pricing is disabled, mark as fetched immediately + if (!context.preferences.useExternalPricingData) { + context.tokenPricesFetchStatus = FetchStatus.Fetched; + } + + // 5. Update interface with scan results after initial render + await snapClient.updateInterface( + id, + , + context, + ); + + // 6. Schedule background jobs + if (context.preferences.useExternalPricingData) { + // Trigger immediate price fetch (1 second), then continue every 20 seconds + await snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationPrices, + duration: 'PT1S', // Start immediately + }); + } + + // Schedule security scan background refresh (every 20 seconds) + if (transactionScanService) { + await snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationSend, + duration: 'PT20S', + }); + } + + // 7. Return the dialog promise immediately (don't await it!) + // Cleanup happens in the background refresh handler when it detects the interface is gone + return dialogPromise; +} diff --git a/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts new file mode 100644 index 00000000..8de8fbb6 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/types.ts @@ -0,0 +1,28 @@ +import type { Json } from '@metamask/snaps-sdk'; + +import type { SpotPrices } from '../../../../clients/price-api/types'; +import type { Network } from '../../../../constants'; +import type { AssetEntity } from '../../../../entities/assets'; +import type { ComputeFeeResult } from '../../../../services/send/types'; +import type { TransactionScanResult } from '../../../../services/transaction-scan/types'; +import type { FetchStatus, Preferences } from '../../../../types/snap'; + +export const CONFIRM_TRANSACTION_INTERFACE_NAME = 'confirmTransaction'; + +export type ConfirmTransactionRequestContext = { + origin: string; + scope: Network; + fromAddress: string | null; + toAddress: string | null; + amount: string | null; + fees: ComputeFeeResult; + asset: AssetEntity; + preferences: Preferences; + networkImage: string; + tokenPrices: SpotPrices; + tokenPricesFetchStatus: FetchStatus; + scan: TransactionScanResult | null; + scanFetchStatus: FetchStatus; + transactionRawData: Json; + accountType: string; +}; diff --git a/merged-packages/tron-wallet-snap/src/utils/assertOrThrow.ts b/merged-packages/tron-wallet-snap/src/utils/assertOrThrow.ts new file mode 100644 index 00000000..80ebc6ae --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/assertOrThrow.ts @@ -0,0 +1,24 @@ +import type { Struct } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; + +/** + * Asserts that a value passes a struct and throws an error if it does not. + * + * @param value - The value to assert. + * @param struct - The struct to assert. + * @param errorToThrow - The error to throw. + * @param errorToThrow.cause - The cause of the error to throw. + * @throws The error if the value does not pass the struct. + */ +export function assertOrThrow( + value: unknown, + struct: Struct, + errorToThrow: { cause?: unknown }, +): asserts value is Type { + try { + assert(value, struct); + } catch (error) { + errorToThrow.cause = error; + throw errorToThrow as Error; + } +} diff --git a/merged-packages/tron-wallet-snap/src/utils/buildUrl.test.ts b/merged-packages/tron-wallet-snap/src/utils/buildUrl.test.ts new file mode 100644 index 00000000..2d1c4974 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/buildUrl.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-script-url */ +import { buildUrl } from './buildUrl'; + +describe('buildUrl', () => { + it('combines base URL and path correctly', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + }); + expect(result).toBe('https://api.example.com/users'); + }); + + it('adds single query parameter', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users?id=123'); + }); + + it('adds multiple query parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { id: '123', name: 'john', role: 'admin' }, + }); + expect(result).toBe( + 'https://api.example.com/users?id=123&name=john&role=admin', + ); + }); + + it('handles path parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users/{id}', + pathParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users/123'); + }); + + it('handles trailing slash in base URL', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com/', + path: '/users', + queryParams: { id: '123' }, + }); + expect(result).toBe('https://api.example.com/users?id=123'); + }); + + it('throws error for invalid base URL', () => { + expect(() => + buildUrl({ + baseUrl: 'invalid-url', + path: '/users', + queryParams: {}, + }), + ).toThrow('Invalid URL format'); + }); + + // Security validation tests + it('prevents XSS in query parameters', () => { + expect(() => + buildUrl({ + baseUrl: 'https://api.example.com', + path: '/search', + queryParams: { + q: '', + callback: 'javascript:alert(1)', + }, + }), + ).toThrow('URL contains potentially malicious patterns'); + }); + + it('prevents path traversal attacks', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/../../../etc/passwd', + queryParams: {}, + }); + expect(result).toBe('https://api.example.com/etc/passwd'); + }); + + it('handles null and undefined query parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { + id: null as unknown as string, + name: undefined as unknown as string, + valid: 'data', + }, + }); + expect(result).toBe('https://api.example.com/users?valid=data'); + }); + + it('prevents protocol switching in parameters', () => { + expect(() => + buildUrl({ + baseUrl: 'https://api.example.com', + path: '/redirect', + queryParams: { + url: 'javascript://alert(1)', + next: 'data:text/html,', + }, + }), + ).toThrow('URL contains potentially malicious patterns'); + }); + + it('handles empty path segments', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '//path//to//resource//', + queryParams: {}, + }); + expect(result).toBe('https://api.example.com/path/to/resource'); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts b/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts new file mode 100644 index 00000000..a05480fe --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/buildUrl.ts @@ -0,0 +1,78 @@ +import { assert } from '@metamask/superstruct'; + +import { sanitizeControlCharacters, sanitizeUri } from './sanitize'; +import { UrlStruct } from '../validation/structs'; + +export type BuildUrlParams = { + baseUrl: string; + path: string; + pathParams?: Record | undefined; + queryParams?: Record | undefined; + encodePathParams?: boolean; +}; + +/** + * Builds a URL with the given base URL and parameters: + * - The `URL` API provides proper URL parsing and encoding. + * - The `path` is sanitized to prevent path traversal attacks. + * - Path and query parameters are sanitized to remove control characters. + * + * Ensures that the built URL is safe, valid, and sanitized. + * + * @param params - The parameters to build the URL from. + * @returns The built URL. + */ +export function buildUrl(params: BuildUrlParams): string { + const { + baseUrl, + path, + pathParams, + queryParams, + encodePathParams = true, + } = params; + + // Validate and sanitize base URL + const sanitizedBaseUrl = sanitizeUri(baseUrl); + if (sanitizedBaseUrl === '') { + throw new Error('Invalid URL format'); + } + assert(sanitizedBaseUrl, UrlStruct); + + const pathWithParams = path.replace(/\{(\w+)\}/gu, (_match, key: string) => { + const value = pathParams?.[key]; + if (value === undefined) { + throw new Error(`Path parameter ${key} is undefined`); + } + // Sanitize path parameter values to remove control characters + const sanitizedValue = sanitizeControlCharacters(value); + return encodePathParams + ? encodeURIComponent(sanitizedValue) + : sanitizedValue; + }); + + const cleanPath = pathWithParams + .replace(/^\/+/u, '') // Remove leading slashes + .replace(/\/+/gu, '/') // Replace multiple slashes with single + .replace(/\/+$/u, ''); // Remove trailing slashes + + // Ensure base URL has trailing slash for proper path appending + const normalizedBaseUrl = sanitizedBaseUrl.endsWith('/') + ? sanitizedBaseUrl + : `${sanitizedBaseUrl}/`; + + const url = new URL(cleanPath, normalizedBaseUrl); + Object.entries(queryParams ?? {}) + .filter(([_key, value]) => value !== undefined) + .filter(([_key, value]) => value !== null) + .forEach(([key, value]) => { + if (value) { + // Sanitize query parameter values to remove control characters + const sanitizedValue = sanitizeControlCharacters(value); + url.searchParams.append(key, sanitizedValue); + } + }); + + const builtUrl = url.toString(); + assert(builtUrl, UrlStruct); + return builtUrl; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/conversion.test.ts b/merged-packages/tron-wallet-snap/src/utils/conversion.test.ts new file mode 100644 index 00000000..1dd1ce16 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/conversion.test.ts @@ -0,0 +1,154 @@ +import { BigNumber } from 'bignumber.js'; + +import { toRawAmount, toUiAmount, trxToSun, sunToTrx } from './conversion'; + +describe('Tron conversion utils', () => { + describe('trxToSun', () => { + it('converts whole TRX to Sun', () => { + expect(trxToSun(1)).toBe('1000000'); + expect(trxToSun(100)).toBe('100000000'); + expect(trxToSun(0)).toBe('0'); + }); + + it('converts fractional TRX to Sun', () => { + expect(trxToSun(0.5)).toBe('500000'); + expect(trxToSun(1.5)).toBe('1500000'); + expect(trxToSun(0.000001)).toBe('1'); + }); + + it('truncates excess decimals (more than 6)', () => { + // 1.0000001 TRX should become 1000000 Sun (not 1000000.1) + expect(trxToSun(1.0000001)).toBe('1000000'); + expect(trxToSun(1.9999999)).toBe('1999999'); + }); + + it('handles string inputs', () => { + expect(trxToSun('1')).toBe('1000000'); + expect(trxToSun('0.5')).toBe('500000'); + }); + + it('handles BigNumber inputs', () => { + expect(trxToSun(new BigNumber('1'))).toBe('1000000'); + expect(trxToSun(new BigNumber('0.5'))).toBe('500000'); + }); + + it('handles large amounts', () => { + expect(trxToSun(1000000)).toBe('1000000000000'); + expect(trxToSun('1000000000000')).toBe('1000000000000000000'); + }); + + it('returns a string', () => { + expect(typeof trxToSun(1)).toBe('string'); + }); + }); + + describe('sunToTrx', () => { + it('converts Sun to TRX', () => { + expect(sunToTrx(1000000).toString()).toBe('1'); + expect(sunToTrx(500000).toString()).toBe('0.5'); + expect(sunToTrx(1).toString()).toBe('0.000001'); + expect(sunToTrx(0).toString()).toBe('0'); + }); + + it('handles string inputs', () => { + expect(sunToTrx('1000000').toString()).toBe('1'); + }); + + it('handles BigNumber inputs', () => { + expect(sunToTrx(new BigNumber('1000000')).toString()).toBe('1'); + }); + + it('handles large amounts', () => { + expect(sunToTrx('1000000000000000000').toString()).toBe('1000000000000'); + }); + }); + + describe('toRawAmount', () => { + it('converts UI amount to raw amount with 6 decimals (like TRX)', () => { + expect(toRawAmount(1, 6)).toBe('1000000'); + expect(toRawAmount(0.5, 6)).toBe('500000'); + expect(toRawAmount(0.000001, 6)).toBe('1'); + }); + + it('converts UI amount to raw amount with 18 decimals (like some TRC20)', () => { + expect(toRawAmount(1, 18)).toBe('1000000000000000000'); + expect(toRawAmount(0.5, 18)).toBe('500000000000000000'); + }); + + it('converts UI amount to raw amount with 0 decimals', () => { + expect(toRawAmount(100, 0)).toBe('100'); + expect(toRawAmount(1.5, 0)).toBe('1'); // Truncates + }); + + it('converts UI amount to raw amount with 3 decimals', () => { + expect(toRawAmount(1, 3)).toBe('1000'); + expect(toRawAmount(1.234, 3)).toBe('1234'); + expect(toRawAmount(1.2345, 3)).toBe('1234'); // Truncates + }); + + it('truncates excess decimals', () => { + // With 6 decimals, 1.0000001 should become 1000000 (not 1000000.1) + expect(toRawAmount(1.0000001, 6)).toBe('1000000'); + }); + + it('handles string inputs', () => { + expect(toRawAmount('1.5', 6)).toBe('1500000'); + }); + + it('handles BigNumber inputs', () => { + expect(toRawAmount(new BigNumber('1.5'), 6)).toBe('1500000'); + }); + + it('returns a string', () => { + expect(typeof toRawAmount(1, 6)).toBe('string'); + }); + }); + + describe('toUiAmount', () => { + it('converts raw amount to UI amount with 6 decimals', () => { + expect(toUiAmount(1000000, 6).toString()).toBe('1'); + expect(toUiAmount(500000, 6).toString()).toBe('0.5'); + expect(toUiAmount(1, 6).toString()).toBe('0.000001'); + }); + + it('converts raw amount to UI amount with 18 decimals', () => { + expect(toUiAmount('1000000000000000000', 18).toString()).toBe('1'); + expect(toUiAmount('500000000000000000', 18).toString()).toBe('0.5'); + }); + + it('converts raw amount to UI amount with 0 decimals', () => { + expect(toUiAmount(100, 0).toString()).toBe('100'); + }); + + it('handles string inputs', () => { + expect(toUiAmount('1500000', 6).toString()).toBe('1.5'); + }); + + it('handles BigNumber inputs', () => { + expect(toUiAmount(new BigNumber('1500000'), 6).toString()).toBe('1.5'); + }); + }); + + describe('round-trip conversions', () => { + it('trxToSun and sunToTrx are inverses for whole numbers', () => { + const original = 100; + const sun = trxToSun(original); + const backToTrx = sunToTrx(sun); + expect(backToTrx.toNumber()).toBe(original); + }); + + it('toRawAmount and toUiAmount are inverses for whole numbers', () => { + const original = 100; + const raw = toRawAmount(original, 6); + const backToUi = toUiAmount(raw, 6); + expect(backToUi.toNumber()).toBe(original); + }); + + it('handles fractional amounts that fit within decimals', () => { + const original = 1.5; + const raw = toRawAmount(original, 6); + const backToUi = toUiAmount(raw, 6); + expect(backToUi.toNumber()).toBe(original); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/conversion.ts b/merged-packages/tron-wallet-snap/src/utils/conversion.ts new file mode 100644 index 00000000..1c924f78 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/conversion.ts @@ -0,0 +1,63 @@ +import { BigNumber } from 'bignumber.js'; + +import { SUN_IN_TRX } from '../constants'; + +/** + * Converts TRX to Sun (the smallest unit of TRX). + * 1 TRX = 1,000,000 Sun + * + * @param amountInTrx - The amount of TRX to convert. + * @returns The amount in Sun as an integer string. + */ +export const trxToSun = (amountInTrx: string | number | BigNumber): string => { + return BigNumber(amountInTrx.toString()) + .multipliedBy(SUN_IN_TRX) + .integerValue(BigNumber.ROUND_DOWN) + .toFixed(0); +}; + +/** + * Converts Sun to TRX. + * 1,000,000 Sun = 1 TRX + * + * @param amountInSun - The amount of Sun to convert. + * @returns The amount in TRX as a BigNumber. + */ +export const sunToTrx = ( + amountInSun: string | number | BigNumber, +): BigNumber => { + return BigNumber(amountInSun.toString()).dividedBy(SUN_IN_TRX); +}; + +/** + * Converts a UI amount (human-readable with decimals) to a raw amount (smallest unit). + * For example, with 6 decimals: 1.5 → 1500000 + * + * @param uiAmount - The UI amount to convert. + * @param decimals - The number of decimal places for the token. + * @returns The raw amount as an integer string. + */ +export const toRawAmount = ( + uiAmount: string | number | BigNumber, + decimals: number, +): string => { + return BigNumber(uiAmount.toString()) + .multipliedBy(BigNumber(10).pow(decimals)) + .integerValue(BigNumber.ROUND_DOWN) + .toFixed(0); +}; + +/** + * Converts a raw amount (smallest unit) to a UI amount (human-readable with decimals). + * For example, with 6 decimals: 1500000 → 1.5 + * + * @param rawAmount - The raw amount to convert. + * @param decimals - The number of decimal places for the token. + * @returns The UI amount as a BigNumber. + */ +export const toUiAmount = ( + rawAmount: string | number | BigNumber, + decimals: number, +): BigNumber => { + return BigNumber(rawAmount.toString()).dividedBy(BigNumber(10).pow(decimals)); +}; diff --git a/merged-packages/tron-wallet-snap/src/utils/errors.test.ts b/merged-packages/tron-wallet-snap/src/utils/errors.test.ts new file mode 100644 index 00000000..0945bf98 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/errors.test.ts @@ -0,0 +1,177 @@ +import { SnapError } from '@metamask/snaps-sdk'; + +import { withCatchAndThrowSnapError } from './errors'; +import logger from './logger'; + +// Mock the logger to avoid actual console output during tests +jest.mock('./logger', () => ({ + error: jest.fn(), +})); + +describe('errors', () => { + const mockLogger = logger as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('handle', () => { + it('returns the result when the function succeeds', async () => { + const mockFn = jest.fn().mockResolvedValue('success'); + + const result = await withCatchAndThrowSnapError(mockFn); + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('handles and re-throws errors as SnapError', async () => { + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('logs errors with the correct scope and error details', async () => { + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + try { + await withCatchAndThrowSnapError(mockFn); + } catch { + // Expected to throw + } + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: expect.any(SnapError) }, + expect.stringContaining(`[SnapError]`), + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + const logCall = mockLogger.error.mock.calls[0]; + const loggedError = logCall?.[0]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + }); + + it('handles non-Error objects and converts them to SnapError', async () => { + const nonErrorValue = 'string error'; + const mockFn = jest.fn().mockRejectedValue(nonErrorValue); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + const logCall = mockLogger.error.mock.calls[0]; + const loggedError = logCall?.[0]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + }); + + it('handles null and undefined errors', async () => { + const mockFn = jest.fn().mockRejectedValue(null); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('preserves the original error message in the SnapError', async () => { + const originalError = new Error('Custom error message'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + let caughtError: unknown; + try { + await withCatchAndThrowSnapError(mockFn); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(SnapError); + const snapError = caughtError as SnapError; + expect(snapError.message).toBe('Custom error message'); + }); + + it('handles async functions that return different types', async () => { + const testCases = [ + { value: 42, type: 'number' }, + { value: { key: 'value' }, type: 'object' }, + { value: [1, 2, 3], type: 'array' }, + { value: true, type: 'boolean' }, + { value: null, type: 'null' }, + ]; + + for (const testCase of testCases) { + const mockFn = jest.fn().mockResolvedValue(testCase.value); + + const result = await withCatchAndThrowSnapError(mockFn); + + expect(result).toBe(testCase.value); + expect(mockLogger.error).not.toHaveBeenCalled(); + } + }); + + it('handles functions that throw different error types', async () => { + const errorTypes = [ + new TypeError('Type error'), + new ReferenceError('Reference error'), + new RangeError('Range error'), + new SyntaxError('Syntax error'), + ]; + + for (const errorType of errorTypes) { + const mockFn = jest.fn().mockRejectedValue(errorType); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + } + + expect(mockLogger.error).toHaveBeenCalledTimes(errorTypes.length); + const logCalls = mockLogger.error.mock.calls; + expect(logCalls).toHaveLength(errorTypes.length); + + for (let i = 0; i < errorTypes.length; i++) { + const logCall = logCalls[i]; + const loggedError = logCall?.[0]?.error; + expect(loggedError).toBeInstanceOf(SnapError); + expect(loggedError?.message).toBe(errorTypes[i]?.message); + } + }); + + it('includes error stack trace in the logged error', async () => { + const originalError = new Error('Test error'); + originalError.stack = 'Error: Test error\n at test.js:1:1'; + const mockFn = jest.fn().mockRejectedValue(originalError); + + try { + await withCatchAndThrowSnapError(mockFn); + } catch { + // Expected to throw + } + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: expect.any(SnapError) }, + expect.stringContaining('[SnapError]'), + ); + }); + + it('handles functions that throw promises', async () => { + const rejectedPromise = Promise.reject(new Error('Promise error')); + const mockFn = jest.fn().mockImplementation(async () => rejectedPromise); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/errors.ts b/merged-packages/tron-wallet-snap/src/utils/errors.ts new file mode 100644 index 00000000..d7b67d75 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/errors.ts @@ -0,0 +1,118 @@ +import { + ChainDisconnectedError, + DisconnectedError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + MethodNotFoundError, + MethodNotSupportedError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + SnapError, + TransactionRejected, + UnauthorizedError, + UnsupportedMethodError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; + +import logger from './logger'; + +/** + * Sanitizes error messages that may contain sensitive cryptographic information. + * This prevents leaking details about private keys, entropy, or derivation paths. + * + * @param error - The error to sanitize. + * @returns A sanitized error with generic message if sensitive info detected. + */ +// TODO: Replace `any` with type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function sanitizeSensitiveError(error: any): Error { + const message = error?.message?.toLowerCase() ?? ''; + const stack = error?.stack?.toLowerCase() ?? ''; + + // Check for sensitive keywords in error message or stack trace + const sensitiveKeywords = [ + 'private', + 'key', + 'entropy', + 'mnemonic', + 'seed', + 'derivation', + 'bip32', + 'bip44', + 'secret', + ]; + + const containsSensitiveInfo = sensitiveKeywords.some( + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (keyword) => message.includes(keyword) || stack.includes(keyword), + ); + + if (containsSensitiveInfo) { + // Return generic error without exposing sensitive details + const sanitizedError = new Error( + 'Key derivation failed. Please check your connection and try again.', + ); + // Preserve error type if it's a Snap error + if (isSnapRpcError(error)) { + return error.constructor ? new error.constructor() : sanitizedError; + } + return sanitizedError; + } + + return error; +} + +/** + * Determines if the given error is a Snap RPC error. + * + * @param error - The error instance to be checked. + * @returns A boolean indicating whether the error is a Snap RPC error. + */ +export function isSnapRpcError(error: Error): boolean { + const errors = [ + SnapError, + MethodNotFoundError, + UserRejectedRequestError, + MethodNotSupportedError, + MethodNotFoundError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + TransactionRejected, + ChainDisconnectedError, + DisconnectedError, + UnauthorizedError, + UnsupportedMethodError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + ]; + return errors.some((errType) => error instanceof errType); +} + +export const withCatchAndThrowSnapError = async ( + fn: () => Promise, +): Promise => { + try { + return await fn(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (errorInstance: any) { + const error = isSnapRpcError(errorInstance) + ? errorInstance + : new SnapError(errorInstance); + + logger.error( + { error }, + `[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`, + ); + + throw error; + } +}; diff --git a/merged-packages/tron-wallet-snap/src/utils/executeOnChainActions.ts b/merged-packages/tron-wallet-snap/src/utils/executeOnChainActions.ts new file mode 100644 index 00000000..1911cf04 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/executeOnChainActions.ts @@ -0,0 +1,60 @@ +import type { TronWeb } from 'tronweb'; + +import type { SnapClient } from '../clients/snap/SnapClient'; +import type { TronWebFactory } from '../clients/tronweb/TronWebFactory'; +import type { Network } from '../constants'; +import type { TronKeyringAccount } from '../entities/keyring-account'; +import { BackgroundEventMethod } from '../handlers/cronjob'; +import type { AccountsService } from '../services/accounts/AccountsService'; + +/** + * Derives a keypair, creates an authenticated TronWeb client, + * builds/signs/broadcasts each transaction, then schedules + * an account synchronization background event. + * + * @param params - The parameters for the on-chain actions. + * @param params.accountsService - Service to derive the keypair from the account. + * @param params.tronWebFactory - Factory to create a TronWeb client. + * @param params.snapClient - Client to schedule background events. + * @param params.account - The account to derive the keypair from. + * @param params.scope - The network scope. + * @param params.buildTransactions - Callback that receives an authenticated TronWeb + * client and returns the transactions to sign and broadcast. + */ +export async function executeOnChainActions({ + accountsService, + tronWebFactory, + snapClient, + account, + scope, + buildTransactions, +}: { + accountsService: AccountsService; + tronWebFactory: TronWebFactory; + snapClient: SnapClient; + account: TronKeyringAccount; + scope: Network; + buildTransactions: (tronWeb: TronWeb) => Promise; +}): Promise { + const { privateKeyHex } = await accountsService.deriveTronKeypair({ + entropySource: account.entropySource, + derivationPath: account.derivationPath, + }); + const tronWeb = tronWebFactory.createClient(scope, privateKeyHex); + + const transactions = await buildTransactions(tronWeb); + for (const transaction of transactions) { + const signedTx = await tronWeb.trx.sign( + transaction as Parameters[0], + ); + await tronWeb.trx.sendRawTransaction( + signedTx as Parameters[0], + ); + } + + await snapClient.scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccount, + params: { accountId: account.id }, + duration: 'PT5S', + }); +} diff --git a/merged-packages/tron-wallet-snap/src/utils/formatAmount.test.ts b/merged-packages/tron-wallet-snap/src/utils/formatAmount.test.ts new file mode 100644 index 00000000..7e40100f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/formatAmount.test.ts @@ -0,0 +1,124 @@ +import { formatAmount } from './formatAmount'; + +describe('formatAmount', () => { + describe('invalid values', () => { + it('returns "0" for NaN', () => { + expect(formatAmount('NaN')).toBe('0'); + }); + + it('returns "0" for Infinity', () => { + expect(formatAmount('Infinity')).toBe('0'); + expect(formatAmount('-Infinity')).toBe('0'); + }); + + it('returns "0" for non-numeric strings', () => { + expect(formatAmount('abc')).toBe('0'); + expect(formatAmount('')).toBe('0'); + expect(formatAmount('not a number')).toBe('0'); + }); + }); + + describe('small numbers', () => { + it('formats zero', () => { + expect(formatAmount('0')).toBe('0'); + }); + + it('formats single digit numbers', () => { + expect(formatAmount('5')).toBe('5'); + expect(formatAmount('9')).toBe('9'); + }); + + it('formats two digit numbers', () => { + expect(formatAmount('42')).toBe('42'); + expect(formatAmount('99')).toBe('99'); + }); + + it('formats three digit numbers without separators', () => { + expect(formatAmount('100')).toBe('100'); + expect(formatAmount('999')).toBe('999'); + }); + }); + + describe('large numbers with thousand separators', () => { + it('formats thousands', () => { + expect(formatAmount('1000')).toBe('1,000'); + expect(formatAmount('9999')).toBe('9,999'); + }); + + it('formats millions', () => { + expect(formatAmount('1000000')).toBe('1,000,000'); + expect(formatAmount('1234567')).toBe('1,234,567'); + }); + + it('formats billions', () => { + expect(formatAmount('1000000000')).toBe('1,000,000,000'); + expect(formatAmount('9876543210')).toBe('9,876,543,210'); + }); + }); + + describe('decimal numbers', () => { + it('formats decimals with period separator', () => { + expect(formatAmount('1.5')).toBe('1.5'); + expect(formatAmount('123.456')).toBe('123.456'); + }); + + it('formats large decimals with thousand separators', () => { + expect(formatAmount('1234.5678')).toBe('1,234.5678'); + expect(formatAmount('1000000.123456')).toBe('1,000,000.123456'); + }); + + it('preserves decimal precision', () => { + expect(formatAmount('0.123456789012345678')).toBe('0.123456789012345678'); + expect(formatAmount('1.000000000000000001')).toBe('1.000000000000000001'); + }); + }); + + describe('values out of `Number` 64-bit size', () => { + it('handles extremely large token amounts without scientific notation', () => { + // Values that caused parseFloat to return scientific notation + const largeValue = '999999999999999999999999999999'; + const result = formatAmount(largeValue); + expect(result).not.toContain('e'); + expect(result).not.toContain('E'); + expect(result).toBe('999,999,999,999,999,999,999,999,999,999'); + }); + + it('handles extremely small decimals without scientific notation', () => { + const smallValue = '0.000000000000000001'; + const result = formatAmount(smallValue); + expect(result).not.toContain('e'); + expect(result).not.toContain('E'); + expect(result).toBe('0.000000000000000001'); + }); + + it('handles values that would cause NaN in parseFloat', () => { + // Very large numbers that parseFloat cannot handle + const extremeValue = + '123456789012345678901234567890123456789012345678901234567890'; + const result = formatAmount(extremeValue); + expect(result).not.toBe('0'); + expect(result).not.toContain('e'); + }); + }); + + describe('negative numbers', () => { + it('formats negative integers', () => { + expect(formatAmount('-100')).toBe('-100'); + expect(formatAmount('-1000')).toBe('-1,000'); + }); + + it('formats negative decimals', () => { + expect(formatAmount('-1.5')).toBe('-1.5'); + expect(formatAmount('-1234.567')).toBe('-1,234.567'); + }); + }); + + describe('consistent decimal separator', () => { + it('always uses period as decimal separator', () => { + // Regardless of any locale considerations, we always use '.' + const result = formatAmount('1234.5678'); + expect(result).toContain('.'); + expect(result).not.toMatch(/,\d{4}/u); // Should not have comma before 4 digits (would indicate German-style) + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/formatAmount.ts b/merged-packages/tron-wallet-snap/src/utils/formatAmount.ts new file mode 100644 index 00000000..d6b745e5 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/formatAmount.ts @@ -0,0 +1,22 @@ +import { BigNumber } from 'bignumber.js'; + +/** + * Formats a numeric string with thousand separators using BigNumber. + * Handles extreme values without precision loss. + * + * @param value - The numeric string to format. + * @returns The formatted string with separators. + */ +export function formatAmount(value: string): string { + const bn = new BigNumber(value); + + if (!bn.isFinite()) { + return '0'; + } + + return bn.toFormat({ + groupSize: 3, + groupSeparator: ',', + decimalSeparator: '.', + }); +} diff --git a/merged-packages/tron-wallet-snap/src/utils/formatFiat.ts b/merged-packages/tron-wallet-snap/src/utils/formatFiat.ts new file mode 100644 index 00000000..cc456d67 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/formatFiat.ts @@ -0,0 +1,26 @@ +import { BigNumber } from 'bignumber.js'; + +/** + * Formats a number as currency. + * + * @param amount - The amount of money. + * @param currency - The currency to format the amount as. + * @param locale - The locale to use for number formatting. + * @returns The formatted currency string. + */ +export function formatFiat( + amount: string, + currency: string, + locale: string, +): string { + const bigAmount = new BigNumber(amount); + const amountNumber = bigAmount.toNumber(); + const [localeCode] = locale.split('_'); + + return amountNumber.toLocaleString(localeCode, { + style: 'currency', + currency, + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); +} diff --git a/merged-packages/tron-wallet-snap/src/utils/formatOrigin.test.ts b/merged-packages/tron-wallet-snap/src/utils/formatOrigin.test.ts new file mode 100644 index 00000000..a38bd86e --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/formatOrigin.test.ts @@ -0,0 +1,46 @@ +import { formatOrigin } from './formatOrigin'; + +describe('formatOrigin', () => { + it('formats "metamask" as "MetaMask"', () => { + expect(formatOrigin('metamask')).toBe('MetaMask'); + }); + + it('formats "METAMASK" as "MetaMask"', () => { + expect(formatOrigin('METAMASK')).toBe('MetaMask'); + }); + + it('formats "MetaMask" as "MetaMask"', () => { + expect(formatOrigin('MetaMask')).toBe('MetaMask'); + }); + + it('formats "MeTaMaSk" (mixed case) as "MetaMask"', () => { + expect(formatOrigin('MeTaMaSk')).toBe('MetaMask'); + }); + + it('extracts hostname from valid URLs', () => { + expect(formatOrigin('https://dapp.example.com')).toBe('dapp.example.com'); + expect(formatOrigin('http://example.com')).toBe('example.com'); + expect(formatOrigin('https://subdomain.example.com:8080')).toBe( + 'subdomain.example.com', + ); + expect(formatOrigin('http://localhost:3000')).toBe('localhost'); + expect(formatOrigin('https://example.com/path/to/page')).toBe( + 'example.com', + ); + }); + + it('returns original value for invalid URLs', () => { + // Note: These should be rejected by validation, but formatOrigin is lenient + expect(formatOrigin('example.com')).toBe('example.com'); + expect(formatOrigin('not-a-url')).toBe('not-a-url'); + expect(formatOrigin('just some text')).toBe('just some text'); + }); + + it('returns "Unknown" for undefined', () => { + expect(formatOrigin(undefined)).toBe('Unknown'); + }); + + it('returns "Unknown" for empty string', () => { + expect(formatOrigin('')).toBe('Unknown'); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/formatOrigin.ts b/merged-packages/tron-wallet-snap/src/utils/formatOrigin.ts new file mode 100644 index 00000000..316b05c4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/formatOrigin.ts @@ -0,0 +1,25 @@ +/** + * Formats an origin for display purposes. + * + * @param origin - The origin string to format (e.g., 'metamask', 'https://example.com'). + * @returns The formatted origin string (e.g., 'MetaMask', 'example.com'). + */ +export function formatOrigin(origin: string | undefined): string { + if (!origin) { + return 'Unknown'; + } + + // Special case: format 'metamask' as 'MetaMask' (case-insensitive) + if (origin.toLowerCase() === 'metamask') { + return 'MetaMask'; + } + + // Try to extract hostname from URL + try { + return new URL(origin).hostname; + } catch { + // If not a valid URL, return the original value + // This shouldn't happen if validation is working correctly + return origin; + } +} diff --git a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts new file mode 100644 index 00000000..633a4a33 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts @@ -0,0 +1,48 @@ +import { Network } from '../constants'; +import { getExplorerUrl } from './getExplorerUrl'; + +describe('getExplorerUrl', () => { + // Set up environment variables for testing + beforeAll(() => { + // eslint-disable-next-line no-restricted-globals + process.env.EXPLORER_MAINNET_BASE_URL = 'https://tronscan.org'; + // eslint-disable-next-line no-restricted-globals + process.env.EXPLORER_NILE_BASE_URL = 'https://nile.tronscan.org'; + // eslint-disable-next-line no-restricted-globals + process.env.EXPLORER_SHASTA_BASE_URL = 'https://shasta.tronscan.org'; + }); + + const mockAddress = 'TJRabPrwbZy45savqYt9XgVjvjQvQnQqQq'; + const mockTx = + '4RPWUVqAqW6jHbVuZH5qJuvoJM6EeX9m9Q6PC1RkcYBW3J4zY9LuZPZqNiYNXGm5qL6GJgCB7JqhXqV8vkKxnAHd'; + + it('should generate mainnet URL for address without cluster param', () => { + const url = getExplorerUrl(Network.Mainnet, 'address', mockAddress); + expect(url).toBe(`https://tronscan.org/#/address/${mockAddress}`); + }); + + it('should generate nile URL for address with cluster param', () => { + const url = getExplorerUrl(Network.Nile, 'address', mockAddress); + expect(url).toBe(`https://nile.tronscan.org/#/address/${mockAddress}`); + }); + + it('should generate shasta URL for address with cluster param', () => { + const url = getExplorerUrl(Network.Shasta, 'address', mockAddress); + expect(url).toBe(`https://shasta.tronscan.org/#/address/${mockAddress}`); + }); + + it('should generate mainnet URL for transaction without cluster param', () => { + const url = getExplorerUrl(Network.Mainnet, 'transaction', mockTx); + expect(url).toBe(`https://tronscan.org/#/transaction/${mockTx}`); + }); + + it('should generate nile URL for transaction with cluster param', () => { + const url = getExplorerUrl(Network.Nile, 'transaction', mockTx); + expect(url).toBe(`https://nile.tronscan.org/#/transaction/${mockTx}`); + }); + + it('should generate shasta URL for transaction with cluster param', () => { + const url = getExplorerUrl(Network.Shasta, 'transaction', mockTx); + expect(url).toBe(`https://shasta.tronscan.org/#/transaction/${mockTx}`); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts new file mode 100644 index 00000000..ef7a211c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getExplorerUrl.ts @@ -0,0 +1,35 @@ +import { Network } from '../constants'; +import { buildUrl } from './buildUrl'; + +/** + * Get the Solana Explorer URL for a given scope, type, and value. + * + * @param scope - The scope of the Solana network. + * @param type - The type of the value to explore. + * @param value - The value to explore. + * @returns The Solana Explorer URL. + */ +export function getExplorerUrl( + scope: Network, + type: 'address' | 'transaction', + value: string, +): string { + // TODO: Get these URLs from configuration instead of environment variables + const NETWORK_TO_EXPLORER_PATH = { + /* eslint-disable-next-line no-restricted-globals */ + [Network.Mainnet]: process.env.EXPLORER_MAINNET_BASE_URL as string, + /* eslint-disable-next-line no-restricted-globals */ + [Network.Nile]: process.env.EXPLORER_NILE_BASE_URL as string, + /* eslint-disable-next-line no-restricted-globals */ + [Network.Shasta]: process.env.EXPLORER_SHASTA_BASE_URL as string, + }; + + const baseUrl = NETWORK_TO_EXPLORER_PATH[scope]; + + const url = buildUrl({ + baseUrl, + path: `/#/${type}/${value}`, + }); + + return url; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts new file mode 100644 index 00000000..fc15d695 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/getLowestUnusedIndex.ts @@ -0,0 +1,44 @@ +export type WithIndex = { + index: number; +}; + +/** + * Generating a new index for the KeyringAccount is not as straightforward as one might think. + * We cannot assume that this number will continuosly increase because one can delete an account with + * an index in the middle of the list. The right way to do it is to loop through the keyringAccounts + * and get the lowest index that is not yet used. + * + * This function does precisely that, in a generic way, as it can work with any array of items that + * have a field `index`. + * + * Eg: + * Used Indices: [] -> Lowest is 0. + * Used Indices: [0, 1, 2, 4] -> Lowest is 3. + * + * @param items - The items to check. + * @returns The lowest unused index. + */ +export function getLowestUnusedIndex(items: WithIndex[]): number { + if (items.length === 0) { + return 0; + } + + const usedIndices = items + .map((item) => item.index) + .sort((first, second) => first - second); + + let lowestUnusedIndex = 0; + + for (const usedIndex of usedIndices) { + /** + * From lower to higher, the moment we find a gap, we can use it + */ + if (usedIndex !== lowestUnusedIndex) { + break; + } + + lowestUnusedIndex += 1; + } + + return lowestUnusedIndex; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/hex.test.ts b/merged-packages/tron-wallet-snap/src/utils/hex.test.ts new file mode 100644 index 00000000..d3327ca4 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/hex.test.ts @@ -0,0 +1,27 @@ +import { hexToString } from './hex'; + +describe('hex utilities', () => { + describe('hexToString', () => { + it('decodes hex string to UTF-8', () => { + expect(hexToString('5452433230416473434f4d')).toBe('TRC20AdsCOM'); + }); + + it('decodes another hex string', () => { + expect(hexToString('42657374416473436f696e')).toBe('BestAdsCoin'); + }); + + it('handles lowercase hex', () => { + expect(hexToString('5452433230416473434f4d'.toLowerCase())).toBe( + 'TRC20AdsCOM', + ); + }); + + it('handles 0x prefix', () => { + expect(hexToString('0x5452433230416473434f4d')).toBe('TRC20AdsCOM'); + }); + + it('decodes numeric string from hex', () => { + expect(hexToString('31303035313139')).toBe('1005119'); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/hex.ts b/merged-packages/tron-wallet-snap/src/utils/hex.ts new file mode 100644 index 00000000..883e2444 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/hex.ts @@ -0,0 +1,28 @@ +/** + * Converts a hex-encoded string to UTF-8. + * + * @param value - Hex string (with or without 0x prefix) + * @returns Decoded UTF-8 string + */ +export function hexToString(value: string): string { + const cleanValue = value.startsWith('0x') ? value.slice(2) : value; + + if (cleanValue.length === 0) { + return ''; + } + + if (cleanValue.length % 2 !== 0) { + throw new Error('Invalid hex string: odd length'); + } + + const bytes = new Uint8Array(cleanValue.length / 2); + for (let index = 0; index < cleanValue.length; index += 2) { + const byte = parseInt(cleanValue.slice(index, index + 2), 16); + if (Number.isNaN(byte)) { + throw new Error('Invalid hex string: contains non-hex characters'); + } + bytes[index / 2] = byte; + } + + return new TextDecoder().decode(bytes); +} diff --git a/merged-packages/tron-wallet-snap/src/utils/i18n.ts b/merged-packages/tron-wallet-snap/src/utils/i18n.ts new file mode 100644 index 00000000..60deef29 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/i18n.ts @@ -0,0 +1,39 @@ +import en from '../../locales/en.json'; +import es from '../../locales/es.json'; + +export const locales = { + en: en.messages, + es: es.messages, +}; + +export const FALLBACK_LANGUAGE: Locale = 'en'; + +export type Locale = keyof typeof locales; +export type LocalizedMessage = keyof (typeof locales)[typeof FALLBACK_LANGUAGE]; + +/** + * Fetches the translations based on the user's locale preference. + * Falls back to the default language if the preferred locale is not available. + * + * @param locale - The user's preferred locale. + * @returns A function that gets the translation for a given key. + */ +export function i18n(locale: Locale) { + // Needs to be casted as EN is the main language and we can have the case where + // messages are not yed completed for the other languages + + const messages = locales[locale] ?? locales[FALLBACK_LANGUAGE]; + + return (id: LocalizedMessage, replaces?: Record): string => { + let message = messages?.[id]?.message ?? id; + + if (replaces && message) { + Object.keys(replaces).forEach((key) => { + const regex = new RegExp(`\\{${key}\\}`, 'gu'); + message = message.replace(regex, replaces[key] ?? ''); + }); + } + + return message; + }; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/isFetchStatusLoadingOrFetching.ts b/merged-packages/tron-wallet-snap/src/utils/isFetchStatusLoadingOrFetching.ts new file mode 100644 index 00000000..cb2a0996 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/isFetchStatusLoadingOrFetching.ts @@ -0,0 +1,14 @@ +import { FetchStatus } from '../types/snap'; + +/** + * + * Since both FetchStatus.Loading and FetchStatus.Fetching represent + * a time when a request is in-flight, this helper returns `true` if + * the input status is in one of those two states + * + * @param status the status to check the state of + * @returns whether the status is considered as fetching or loading + */ +export function isFetchStatusLoadingOrFetching(status: FetchStatus): boolean { + return status === FetchStatus.Loading || status === FetchStatus.Fetching; +} diff --git a/merged-packages/tron-wallet-snap/src/utils/logger.ts b/merged-packages/tron-wallet-snap/src/utils/logger.ts new file mode 100644 index 00000000..4a093c28 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/logger.ts @@ -0,0 +1,99 @@ +/* eslint-disable no-empty-function */ + +/** + * A simple logger utility that provides methods for logging messages at different levels. + * For now, it's just a wrapper around console. + * + * @namespace logger + */ + +export type ILogger = { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + log: (...args: any[]) => void; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + info: (...args: any[]) => void; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + warn: (...args: any[]) => void; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: (...args: any[]) => void; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + debug: (...args: any[]) => void; +}; + +const withErrorLogging = + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (logFn: (...args: any[]) => void) => + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (...args: any[]): void => { + logFn(...args); + }; + +/** + * A decorator function that noops if the environment is not local, + * and runs the decorated function otherwise. + * + * @param fn - The function to wrap. + * @returns The wrapped function. + */ +const withNoopInProduction = + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fn: (...args: any[]) => void) => + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (...args: any[]): void => { + // eslint-disable-next-line no-restricted-globals + if (process.env.ENVIRONMENT === 'production') { + return; + } + fn(...args); + }; + +/** + * A basic logger that wraps the console, extending its functionality to properly log Tron errors. + */ +const logger: ILogger = { + log: withNoopInProduction(console.log), + info: withNoopInProduction(console.info), + warn: withNoopInProduction(console.warn), + debug: withNoopInProduction(console.debug), + error: withNoopInProduction(withErrorLogging(console.error)), +}; + +export const noOpLogger: ILogger = { + log: () => {}, + info: () => {}, + warn: () => {}, + debug: () => {}, + error: () => {}, +}; + +export const createPrefixedLogger = ( + _logger: ILogger, + prefix: string, +): ILogger => { + return new Proxy(_logger, { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(target, prop: keyof ILogger): any { + const method = target[prop]; + if (typeof method === 'function') { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (message: string, ...args: any[]) => { + return method.call(target, prefix, message, ...args); + }; + } + return method; + }, + }); +}; + +export default logger; diff --git a/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts b/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts new file mode 100644 index 00000000..17c513cf --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/mockLogger.ts @@ -0,0 +1,9 @@ +import type { ILogger } from './logger'; + +export const mockLogger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +} as unknown as ILogger; diff --git a/merged-packages/tron-wallet-snap/src/utils/safeMerge.test.ts b/merged-packages/tron-wallet-snap/src/utils/safeMerge.test.ts new file mode 100644 index 00000000..93666ab1 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/safeMerge.test.ts @@ -0,0 +1,119 @@ +import { safeMerge } from './safeMerge'; + +describe('safeMerge', () => { + it('merges two objects and keeps existing values when overrider has undefined', () => { + const overridee = { name: 'John', age: 25 }; + const overrider = { name: undefined, title: 'Developer' }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + name: 'John', + age: 25, + title: 'Developer', + }); + }); + + it('overrides values when overrider has non-undefined values', () => { + const overridee = { name: 'John', age: 25 }; + const overrider = { name: 'Jane', title: 'Engineer' }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + name: 'Jane', + age: 25, + title: 'Engineer', + }); + }); + + it('handles empty objects', () => { + const overridee = {}; + const overrider = {}; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({}); + }); + + it('handles objects with null values', () => { + const overridee = { name: 'John', age: null }; + const overrider = { name: null, title: 'Developer' }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + name: 'John', + age: null, + title: 'Developer', + }); + }); + + it('handles nested objects', () => { + const overridee = { + user: { + name: 'John', + details: { age: 25 }, + }, + }; + const overrider = { + user: { + name: undefined, + details: { location: 'NYC' }, + }, + }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + user: { + name: undefined, + details: { location: 'NYC' }, + }, + }); + }); + + it('filters out empty objects in overrider', () => { + const overridee = { name: 'John', settings: { theme: 'dark' } }; + const overrider = { name: 'Jane', settings: {} }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + name: 'Jane', + settings: { theme: 'dark' }, + }); + }); + + it('keeps non-empty objects in overrider', () => { + const overridee = { settings: { theme: 'dark' } }; + const overrider = { settings: { language: 'en' } }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + settings: { language: 'en' }, + }); + }); + + it('merges multiple empty and non-empty nested objects', () => { + const overridee = { + a: { x: 1 }, + b: { y: 2 }, + c: { z: 3 }, + }; + const overrider = { + a: {}, + b: { y: 5 }, + c: {}, + }; + + const result = safeMerge(overridee, overrider); + + expect(result).toStrictEqual({ + a: { x: 1 }, + b: { y: 5 }, + c: { z: 3 }, + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts b/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts new file mode 100644 index 00000000..b860867f --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/safeMerge.ts @@ -0,0 +1,28 @@ +/** + * Merges two objects, keeping values from the overridee object when the overrider's corresponding properties are undefined, + * null, or empty objects. Non-undefined values from the overrider take precedence. Empty objects in the overrider are + * filtered out to preserve the overridee's values. + * + * @param overridee - The object to override. + * @param overrider - The object to override with. + * @returns The merged object. + * @example + * const overridee = { name: 'John' }; + * const overrider = { name: undefined, age: 30 }; + * const merged = safeMerge(overridee, overrider); + * // merged is { name: 'John' } + */ +export const safeMerge = ( + overridee: TOverridee, + overrider: TOverrider, +): TOverridee & TOverrider => ({ + ...overridee, + ...(Object.fromEntries( + Object.entries(overrider).filter( + ([_key, value]) => + value !== undefined && + value !== null && + (!value || typeof value !== 'object' || Object.keys(value).length > 0), + ), + ) as TOverrider), +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/sanitize.ts b/merged-packages/tron-wallet-snap/src/utils/sanitize.ts new file mode 100644 index 00000000..dfcaa3d6 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/sanitize.ts @@ -0,0 +1,44 @@ +/** + * Removes control characters from a string. + * Control characters can be used for injection attacks and should be stripped from user input. + * + * @param input - The string to sanitize. + * @returns The sanitized string with control characters removed. + */ +export function sanitizeControlCharacters(input: string): string { + if (!input || typeof input !== 'string') { + return ''; + } + + // Remove all control characters except tab + // eslint-disable-next-line no-control-regex + return input.replace(/[\u0000-\u0008\u000A-\u001F\u007F]/gu, ''); +} + +/** + * Validates and sanitizes a URI. + * + * @param uri - The URI to validate and sanitize. + * @returns The sanitized URI or empty string if invalid. + */ +export function sanitizeUri(uri: string): string { + if (!uri || typeof uri !== 'string') { + return ''; + } + + const sanitized = sanitizeControlCharacters(uri); + + try { + const url = new URL(sanitized); + const allowedProtocols = ['http:', 'https:', 'wss:']; + if (!allowedProtocols.includes(url.protocol)) { + return ''; + } + if (sanitized.length > 2048) { + return ''; + } + return sanitized; + } catch { + return ''; + } +} diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts new file mode 100644 index 00000000..578d822c --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.test.ts @@ -0,0 +1,95 @@ +/* eslint-disable jest/prefer-strict-equal */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { BigNumber } from 'bignumber.js'; + +import { deserialize } from './deserialize'; + +describe('deserialize', () => { + it('deserializes primitive values', () => { + expect(deserialize('test')).toBe('test'); + expect(deserialize(42)).toBe(42); + expect(deserialize(true)).toBe(true); + expect(deserialize(null)).toBeNull(); + }); + + it('deserializes special serialized types', () => { + expect(deserialize({ __type: 'undefined' })).toBeUndefined(); + expect( + deserialize({ __type: 'bigint', value: '9007199254740991' }), + ).toStrictEqual(BigInt(9007199254740991)); + expect( + deserialize({ __type: 'BigNumber', value: '123456789.123456789' }), + ).toStrictEqual(new BigNumber('123456789.123456789')); + }); + + it('deserializes arrays with mixed types', () => { + expect( + deserialize([ + 1, + 'hello', + true, + null, + { __type: 'undefined' }, + { __type: 'bigint', value: '9007199254740991' }, + { __type: 'BigNumber', value: '123456789.123456789' }, + ]), + ).toEqual([ + 1, + 'hello', + true, + null, + undefined, + BigInt(9007199254740991), + new BigNumber('123456789.123456789'), + ]); + }); + + it('deserializes objects with nested structures', () => { + const input = { + nested: { + bigNumber: { __type: 'BigNumber', value: '123.456' }, + bigint: { __type: 'bigint', value: '9007199254740991' }, + undefined: { __type: 'undefined' }, + }, + array: [ + { __type: 'BigNumber', value: '789.012' }, + { __type: 'bigint', value: '9007199254740992' }, + ], + }; + + const result = deserialize(input); + + expect(result).toEqual({ + nested: { + bigNumber: new BigNumber('123.456'), + bigint: BigInt('9007199254740991'), + undefined, + }, + array: [new BigNumber('789.012'), BigInt('9007199254740992')], + }); + }); + + it('handles non-undefined falsy values correctly', () => { + const input = { + zero: 0, + emptyString: '', + falseValue: false, + nullValue: null, + }; + + const result = deserialize(input); + + expect(result).toStrictEqual({ + zero: 0, + emptyString: '', + falseValue: false, + nullValue: null, + }); + }); + + it('deserializes Uint8Array', () => { + const input = { __type: 'Uint8Array', value: 'AQID' }; + const result = deserialize(input); + expect(result).toStrictEqual(new Uint8Array([1, 2, 3])); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts new file mode 100644 index 00000000..3fc351f8 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/deserialize.ts @@ -0,0 +1,42 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { Serializable } from './types'; + +/** + * Deserializes the passed value from a JSON object to an object with its the original values. + * It transforms the JSON-serializable representation of non-JSON-serializable values back into their original values. + * + * @param serializedValue - The value to deserialize. + * @returns The deserialized value. + */ +export const deserialize = (serializedValue: Json): Serializable => + JSON.parse(JSON.stringify(serializedValue), (_key, value) => { + if (!value) { + return value; + } + + if (value.__type === 'undefined') { + return undefined; + } + + if (value.__type === 'BigNumber') { + return new BigNumber(value.value); + } + + if (value.__type === 'bigint') { + return BigInt(value.value); + } + + if (value.__type === 'Uint8Array') { + // Use TextEncoder to decode base64 string to Uint8Array + const binaryString = atob(value.value); + const bytes = new Uint8Array(binaryString.length); + for (let index = 0; index < binaryString.length; index++) { + bytes[index] = binaryString.charCodeAt(index); + } + return bytes; + } + + return value; + }); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts new file mode 100644 index 00000000..ab07039a --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.test.ts @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { BigNumber } from 'bignumber.js'; + +import { serialize } from './serialize'; + +describe('serialize', () => { + it('serializes primitive values', () => { + expect(serialize('test')).toBe('test'); + expect(serialize(42)).toBe(42); + expect(serialize(true)).toBe(true); + expect(serialize(null)).toBeNull(); + expect(serialize(undefined)).toStrictEqual({ __type: 'undefined' }); + }); + + it('serializes special types', () => { + expect(serialize(BigInt(9007199254740991))).toStrictEqual({ + __type: 'bigint', + value: '9007199254740991', + }); + expect(serialize(new BigNumber('123456789.123456789'))).toStrictEqual({ + __type: 'BigNumber', + value: '123456789.123456789', + }); + }); + + it('serializes arrays with mixed types', () => { + const input = [undefined, new BigNumber('123'), BigInt('456')]; + const result = serialize(input); + expect(result).toStrictEqual([ + { __type: 'undefined' }, + { __type: 'BigNumber', value: '123' }, + { __type: 'bigint', value: '456' }, + ]); + }); + + it('serializes objects with nested structures', () => { + const input = { + nested: { + bigNumber: new BigNumber('123.456'), + bigint: BigInt('9007199254740991'), + undefined, + }, + array: [new BigNumber('789.012'), BigInt('9007199254740992')], + }; + + const result = serialize(input); + + expect(result).toStrictEqual({ + nested: { + bigNumber: { __type: 'BigNumber', value: '123.456' }, + bigint: { __type: 'bigint', value: '9007199254740991' }, + undefined: { __type: 'undefined' }, + }, + array: [ + { __type: 'BigNumber', value: '789.012' }, + { __type: 'bigint', value: '9007199254740992' }, + ], + }); + }); + + it('serializes empty objects and arrays', () => { + const input = { + emptyObject: {}, + emptyArray: [], + }; + + const result = serialize(input); + + expect(result).toStrictEqual(input); + }); + + it('serializes deeply nested structures', () => { + const input = { + level1: { + level2: { + level3: { + bigint: BigInt('123'), + undefined, + }, + }, + }, + }; + + const result = serialize(input); + + expect(result).toStrictEqual({ + level1: { + level2: { + level3: { + bigint: { __type: 'bigint', value: '123' }, + undefined: { __type: 'undefined' }, + }, + }, + }, + }); + }); + + it('serializes Uint8Array', () => { + const input = new Uint8Array([1, 2, 3]); + const result = serialize(input); + expect(result).toStrictEqual({ + __type: 'Uint8Array', + value: 'AQID', + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts new file mode 100644 index 00000000..c205ebee --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/serialize.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Json } from '@metamask/snaps-sdk'; +import { BigNumber } from 'bignumber.js'; +import { cloneDeepWith } from 'lodash'; + +import type { Serializable } from './types'; + +/** + * Serializes the passed value to a JSON object so it can be stored in JSON-serializable storage like the snap state and interface context. + * It transforms non-JSON-serializable values into a specific JSON-serializable representation that can be deserialized later. + * + * @param value - The value to serialize. + * @returns The serialized value. + * @throws If an unsupported case is encountered. This indicates a missing implementation. + */ +export const serialize = (value: Serializable): Record => + cloneDeepWith(value, (val: unknown) => { + if (val === undefined) { + return { + __type: 'undefined', + }; + } + + if (val instanceof BigNumber) { + return { + __type: 'BigNumber', + value: val.toString(), + }; + } + + if (typeof val === 'bigint') { + return { + __type: 'bigint', + value: val.toString(), + }; + } + + if (val instanceof Uint8Array) { + // Convert Uint8Array to base64 string without using Buffer + let binaryString = ''; + for (const byte of val) { + binaryString += String.fromCharCode(byte); + } + return { + __type: 'Uint8Array', + value: btoa(binaryString), + }; + } + + // Return undefined to let lodash handle the cloning of other values + return undefined; + }); diff --git a/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts b/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts new file mode 100644 index 00000000..cb8c3fd7 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/serialization/types.ts @@ -0,0 +1,16 @@ +import type { Json } from '@metamask/snaps-sdk'; + +/** + * A primitive value that can be serialized to JSON using the `serialize` function. + */ +export type Serializable = + | Json + | undefined + | null + | bigint + | BigNumber + | Uint8Array + | Serializable[] + | { + [prop: string]: Serializable; + }; diff --git a/merged-packages/tron-wallet-snap/src/utils/tokenToFiat.ts b/merged-packages/tron-wallet-snap/src/utils/tokenToFiat.ts new file mode 100644 index 00000000..a08f54df --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/utils/tokenToFiat.ts @@ -0,0 +1,16 @@ +import { BigNumber } from 'bignumber.js'; + +/** + * Converts a token amount to fiat currency using the provided conversion rate. + * + * @param tokenAmount - The amount of tokens to convert. + * @param rateConversion - The conversion rate from token to fiat. + * @returns The fiat value of the token amount. + */ +export function tokenToFiat( + tokenAmount: string, + rateConversion: number, +): string { + const bigAmount = new BigNumber(tokenAmount); + return bigAmount.multipliedBy(rateConversion).toString(); +} diff --git a/merged-packages/tron-wallet-snap/src/validation/keyring-structs.test.ts b/merged-packages/tron-wallet-snap/src/validation/keyring-structs.test.ts new file mode 100644 index 00000000..7be3c772 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/keyring-structs.test.ts @@ -0,0 +1,411 @@ +import { assert, StructError } from '@metamask/superstruct'; +import { bytesToBase64, bytesToHex, stringToBytes } from '@metamask/utils'; + +import { Network } from '../constants'; +import { + SignMessageRequestStruct, + SignTransactionRequestStruct, + TronKeyringRequestStruct, +} from './structs'; +import { TronMultichainMethod } from '../handlers/keyring-types'; + +// Helper function to convert string to base64 +const toBase64 = (str: string): string => bytesToBase64(stringToBytes(str)); + +// Helper function to convert string to hex +const toHex = (str: string): string => { + return bytesToHex(stringToBytes(str)).slice(2); +}; + +describe('Keyring Validation Structs', () => { + describe('SignMessageRequestStruct', () => { + it('validates valid signMessage params', () => { + const validParams = { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello World'), + }; + + expect(() => assert(validParams, SignMessageRequestStruct)).not.toThrow(); + }); + + it('rejects invalid address', () => { + const invalidParams = { + address: 'invalid-address', + message: toBase64('Hello'), + }; + + expect(() => assert(invalidParams, SignMessageRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects invalid base64 message', () => { + const invalidParams = { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: 'not-base64!!!', + }; + + expect(() => assert(invalidParams, SignMessageRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects missing address', () => { + const invalidParams = { + message: toBase64('Hello'), + }; + + expect(() => assert(invalidParams, SignMessageRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects missing message', () => { + const invalidParams = { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + }; + + expect(() => assert(invalidParams, SignMessageRequestStruct)).toThrow( + StructError, + ); + }); + }); + + describe('SignTransactionRequestStruct', () => { + it('validates valid signTransaction params', () => { + const validParams = { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }; + + expect(() => + assert(validParams, SignTransactionRequestStruct), + ).not.toThrow(); + }); + + it('rejects invalid scope', () => { + const invalidParams = { + address: 'not-a-tron-address', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }; + + expect(() => assert(invalidParams, SignTransactionRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects invalid address', () => { + const invalidParams = { + address: 'not-a-tron-address', + transaction: { + rawDataHex: 'invalid-base64!!!', + type: 'TransferContract', + }, + }; + + expect(() => assert(invalidParams, SignTransactionRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects missing address', () => { + const invalidParams = { + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }; + + expect(() => assert(invalidParams, SignTransactionRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects missing transaction', () => { + const invalidParams = { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + }; + + expect(() => assert(invalidParams, SignTransactionRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects missing transaction type', () => { + const invalidParams = { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + }, + }; + + expect(() => assert(invalidParams, SignTransactionRequestStruct)).toThrow( + StructError, + ); + }); + }); + + describe('TronKeyringRequestStruct', () => { + describe('signMessage requests', () => { + it('validates valid signMessage KeyringRequest', () => { + const validRequest = { + id: '12345678-1234-4234-8234-123456789012', + origin: 'test-dapp.com', + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + expect(() => + assert(validRequest, TronKeyringRequestStruct), + ).not.toThrow(); + }); + + it('rejects invalid account UUID', () => { + const invalidRequest = { + account: 'not-a-uuid', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + expect(() => assert(invalidRequest, TronKeyringRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects missing scope', () => { + const invalidRequest = { + account: '123e4567-e89b-42d3-a456-426614174000', + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + expect(() => assert(invalidRequest, TronKeyringRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects invalid method', () => { + const invalidRequest = { + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Mainnet, + request: { + method: 'invalidMethod', + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + expect(() => assert(invalidRequest, TronKeyringRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects missing params', () => { + const invalidRequest = { + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + }, + }; + + expect(() => assert(invalidRequest, TronKeyringRequestStruct)).toThrow( + StructError, + ); + }); + }); + + describe('signTransaction requests', () => { + it('validates valid signTransaction KeyringRequest', () => { + const validRequest = { + id: '12345678-1234-4234-8234-123456789012', + origin: 'test-dapp.com', + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('tx-data'), + type: 'TransferContract', + }, + }, + }, + }; + + expect(() => + assert(validRequest, TronKeyringRequestStruct), + ).not.toThrow(); + }); + + it('validates signTransaction with different scopes', () => { + const validRequest = { + id: '12345678-1234-4234-8234-123456789012', + origin: 'test-dapp.com', + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Shasta, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + transaction: { + rawDataHex: toHex('transaction-data'), + type: 'TransferContract', + }, + }, + }, + }; + + expect(() => + assert(validRequest, TronKeyringRequestStruct), + ).not.toThrow(); + }); + + it('rejects invalid transaction params', () => { + const invalidRequest = { + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignTransaction, + params: { + // Missing required fields + scope: Network.Mainnet, + }, + }, + }; + + expect(() => assert(invalidRequest, TronKeyringRequestStruct)).toThrow( + StructError, + ); + }); + }); + + describe('edge cases', () => { + it('rejects empty request object', () => { + const invalidRequest = {}; + + expect(() => assert(invalidRequest, TronKeyringRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects null request', () => { + expect(() => assert(null, TronKeyringRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects undefined request', () => { + expect(() => assert(undefined, TronKeyringRequestStruct)).toThrow( + StructError, + ); + }); + + it('rejects request with extra fields', () => { + const requestWithExtra = { + id: '12345678-1234-4234-8234-123456789012', + origin: 'test-dapp.com', + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + extraField: 'should-not-be-here', + }; + + // Note: KeyringRequestStruct does not allow extra fields + // This test verifies strict validation + expect(() => + assert(requestWithExtra, TronKeyringRequestStruct), + ).toThrow(StructError); + }); + }); + + describe('network scopes', () => { + it('validates Mainnet scope', () => { + const request = { + id: '12345678-1234-4234-8234-123456789012', + origin: 'test-dapp.com', + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Mainnet, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + expect(() => assert(request, TronKeyringRequestStruct)).not.toThrow(); + }); + + it('validates Shasta scope', () => { + const request = { + id: '12345678-1234-4234-8234-123456789012', + origin: 'test-dapp.com', + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Shasta, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + expect(() => assert(request, TronKeyringRequestStruct)).not.toThrow(); + }); + + it('validates Nile scope', () => { + const request = { + id: '12345678-1234-4234-8234-123456789012', + origin: 'test-dapp.com', + account: '123e4567-e89b-42d3-a456-426614174000', + scope: Network.Nile, + request: { + method: TronMultichainMethod.SignMessage, + params: { + address: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', + message: toBase64('Hello'), + }, + }, + }; + + expect(() => assert(request, TronKeyringRequestStruct)).not.toThrow(); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.test.ts b/merged-packages/tron-wallet-snap/src/validation/structs.test.ts new file mode 100644 index 00000000..124fbc18 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/structs.test.ts @@ -0,0 +1,253 @@ +/* eslint-disable jest/require-to-throw-message */ +import { assert, is } from '@metamask/superstruct'; + +import { Base58Struct, Base64Struct, UrlStruct, UuidStruct } from './structs'; + +describe('structs', () => { + describe('Uuid', () => { + it('validates valid UUIDs', () => { + const validUuids = [ + 'c747acb9-1b2b-4352-b9da-3d658fcc3cc7', + '2507a426-ac26-43c4-a82a-250f5d999398', + '52d181f4-d050-4971-b448-17c15107fa3b', + ]; + + validUuids.forEach((uuid) => { + expect(() => assert(uuid, UuidStruct)).not.toThrow(); + expect(is(uuid, UuidStruct)).toBe(true); + }); + }); + }); + + describe('UrlStruct', () => { + it('validates valid URLs', () => { + const validUrls = [ + 'http://example.com', + 'https://example.com', + 'https://www.example.com', + 'https://sub.example.com', + 'https://example.com/path', + 'https://example.com/path?query=123', + 'https://example.com/path?query=123&other=456', + 'https://example.com:8080', + 'https://example.com/path-with-hyphens', + 'https://example.com/path_with_underscore', + 'http://localhost:8899', + 'wss://example.com', + ]; + + validUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).not.toThrow(); + expect(is(url, UrlStruct)).toBe(true); + }); + }); + + it('rejects invalid URLs', () => { + const invalidUrls = [ + '', + 'not-a-url', + 'ftp://example.com', + 'example.com', + 'http:/example.com', + 'http://example', + 'http:///example.com', + 'http:// example.com', + // eslint-disable-next-line no-script-url + 'javascript:alert(1)', + 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', + ]; + + invalidUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).toThrow(); + expect(is(url, UrlStruct)).toBe(false); + }); + }); + + it('includes the real parser reason when URL format is invalid', () => { + expect(() => assert('http:// example.com', UrlStruct)).toThrow( + 'Invalid URL format: Invalid URL', + ); + }); + + it('rejects malicious URLs', () => { + const maliciousUrls = [ + // XSS Attacks + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?
', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + + // Additional XSS Variants + 'https://example.com?', + 'https://example.com?', + 'https://example.com?', + 'https://example.com?
', + + // SQL Injection Attempts + 'https://example.com?id=1%27%20OR%20%271%27=%271', + 'https://example.com?id=1%20UNION%20SELECT%20*%20FROM%20users', + + // Directory Traversal + 'https://example.com/../../../etc/passwd', + 'https://example.com/..%2f..%2f..%2fetc%2fpasswd', + + // Command Injection + 'https://example.com?cmd=|ls', + 'https://example.com?cmd=;cat%20/etc/passwd', + + // Protocol Pollution + 'https://example.com\\@evil.com', + 'https://example.com%2f@evil.com', + 'https://example.com?url=javascript:alert(1)', + + // Unicode/UTF-8 Attacks + 'https://example.com/%2e%2e/%2e%2e/%2e%2e/etc/passwd', + 'https://example.com/⒕⒖⒗', + + // CRLF Injection + 'https://example.com?%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK', + + // Open Redirect + 'https://example.com?redirect=//evil.com', + 'https://example.com?url=https://evil.com', + + // HTML Injection without script tags + 'https://example.com?param=test', + 'https://example.com?param=', + + // Data URI schemes + 'data:text/html,', + 'data:application/x-javascript,alert(1)', + + // Null Byte Attacks + 'https://example.com/file.jpg%00.php', + + // Template Injection + // eslint-disable-next-line no-template-curly-in-string + 'https://example.com?${7*7}', + 'https://example.com?#{7*7}', + ]; + maliciousUrls.forEach((url) => { + expect(() => assert(url, UrlStruct)).toThrow(); + expect(is(url, UrlStruct)).toBe(false); + }); + }); + }); + + describe('Base58Struct', () => { + it('validates valid Base58 strings', () => { + const validBase58Strings = [ + '72k1xXWG59wUsYv7h2', // Decoded: "Hello, world!" + '3yZe7d', // Decoded: "Test" + 'JxF12TrwUP45BMd', // Decoded: "Base58 Example" + '5Q444645Hz4hD7AuSj5z8m6jKLd3TxoMwp4Y7UWVKGqy', // Example Solana address + 'Qmf412jQZiuVUtdgnB36FXFX7xg5V6KEbSJ4dpQuhkLyfD', // Example IPFS hash + '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', // All valid Base58 chars + ]; + + validBase58Strings.forEach((base58String) => { + expect(() => assert(base58String, Base58Struct)).not.toThrow(); + expect(is(base58String, Base58Struct)).toBe(true); + }); + }); + + it('rejects invalid Base58 strings', () => { + const invalidBase58Strings = [ + 'invalid-base58-string', + '', // empty string + '0', // 0 is not used in Base58 + 'O', // uppercase O is not used in Base58 + 'l', // lowercase L is not used in Base58 + 'I', // uppercase i is not used in Base58 + 'hello world', // spaces not allowed + 'base+58', // special characters not allowed + '12345!', // exclamation mark not allowed + 'ABC 123', // spaces not allowed + 'TEST@123', // @ symbol not allowed + '你好', // non-ASCII characters + 'BASE_58', // underscore not allowed + 'test\n123', // newlines not allowed + 'test\t123', // tabs not allowed + ]; + + invalidBase58Strings.forEach((base58String) => { + expect(() => assert(base58String, Base58Struct)).toThrow(); + expect(is(base58String, Base58Struct)).toBe(false); + }); + }); + }); + + describe('Base64Struct', () => { + it('validates valid Base64 strings', () => { + const validBase64Strings = [ + 'SGVsbG8sIFdvcmxkIQ==', // "Hello, World!" + 'dGVzdA==', // "test" + 'YWJzb2x1dGU=', // "aBc" + 'aGVsbG8=', // "hello" + 'aGVsbG8sIHdvcmxkIQ==', // "hello, world!" + // Short messages (1-3 characters) + 'YQ==', // "a" (1 character) + 'YWI=', // "ab" (2 characters) + 'YWJj', // "abc" (3 characters) + ]; + + validBase64Strings.forEach((base64String) => { + expect(() => assert(base64String, Base64Struct)).not.toThrow(); + expect(is(base64String, Base64Struct)).toBe(true); + }); + }); + + it('rejects invalid Base64 strings', () => { + const invalidBase64Strings = [ + // Invalid characters + 'ABC!DEF', // Contains '!' which is not in Base64 alphabet + 'ABC DEF', // Contains space which is not in Base64 alphabet + 'ABC_DEF', // Contains '_' which is not in standard Base64 (but is in URL-safe variant) + 'ABC-DEF', // Contains '-' which is not in standard Base64 (but is in URL-safe variant) + + // Invalid padding + 'A=', // Single character with padding (should be 'A===') + 'AB=', // Two characters with single padding (should be 'AB==') + 'ABC=A', // Padding in the middle + 'ABCD=', // Padding when not needed (length is multiple of 4) + 'A===', // Too much padding for a single character + 'AB===', // Too much padding for two characters + 'ABC==', // Too much padding for three characters + + // Invalid length + 'A', // Single character without proper padding + 'AB', // Two characters without proper padding + 'ABC', // Three characters without proper padding + + // Padding in wrong position + '=ABC', // Padding at the beginning + 'A=BC', // Padding in the middle + 'AB=C', // Padding in the middle + + // Mixed issues + 'A=B=C=', // Multiple padding characters in wrong positions + 'AB!C=', // Invalid character and padding + 'ABCDE=', // Length not a multiple of 4 with incorrect padding + + // Empty string + '', // Empty string is not valid Base64 + + // URL-safe Base64 characters in standard Base64 + 'ABC-DEF+GHI', // Mixed URL-safe and standard Base64 + 'ABC_DEF/GHI', // Mixed URL-safe and standard Base64 + ]; + + invalidBase64Strings.forEach((base64String) => { + expect(() => assert(base64String, Base64Struct)).toThrow(base64String); + expect(is(base64String, Base64Struct)).toBe(false); + }); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/validation/structs.ts b/merged-packages/tron-wallet-snap/src/validation/structs.ts new file mode 100644 index 00000000..093d05b3 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/structs.ts @@ -0,0 +1,462 @@ +import { + CaipAssetTypeStruct, + KeyringRequestStruct, + SolMethod, + TrxAccountType, +} from '@metamask/keyring-api'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { + array, + define, + enums, + integer, + literal, + min, + nonempty, + nullable, + number, + object, + optional, + pattern, + record, + refine, + string, + type, + union, + unknown, +} from '@metamask/superstruct'; +import { TronWeb } from 'tronweb'; + +import { Network } from '../constants'; +import { TronMultichainMethod } from '../handlers/keyring-types'; +import { + MaximumResourceCaipAssetTypeStruct, + NativeCaipAssetTypeStruct, + NftCaipAssetTypeStruct, + ResourceCaipAssetTypeStruct, + StakedCaipAssetTypeStruct, + TokenCaipAssetTypeStruct, +} from '../services/assets/types'; + +// create a uuid validation +export const UuidStruct = pattern( + string(), + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u, +); + +export const PositiveNumberStringStruct = pattern( + string(), + /^(?!0\d)(\d+(\.\d+)?)$/u, +); + +/** + * Validates that a string is a valid and safe URL. + * + * It rejects: + * - Non-HTTP/HTTPS/WSS protocols + * - Malformed URL format or incorrect protocol format + * - Invalid hostname format (must follow proper domain naming conventions) + * - Protocol pollution attempts (backslashes, @ symbol, %2f@, %5c@) + * - Invalid hostname characters (backslashes, @ symbol, forward slashes, encoded forward slashes) + * - Directory traversal attempts (../, ..%2f, ..%2F) + * + * Dangerous patterns including: + * - HTML tags. + * - JavaScript protocol. + * - Data URI scheme. + * - Template injection (${...}, #{...}). + * - Command injection (|, ;). + * - CRLF injection. + * - URL credential injection. + * - SQL injection attempts. + * - Open redirect parameters. + * - Non-printable characters. + */ +export const UrlStruct = refine(string(), 'safe-url', (value) => { + try { + // Basic URL validation + const url = new URL(value); + + // Protocol check + const supportedProtocols = ['http:', 'https:', 'wss:']; + if (!supportedProtocols.includes(url.protocol)) { + return `URL must use one of the following protocols: ${supportedProtocols.join(', ')}`; + } + + // Validate URL format + if (!value.match(/^(https?|wss):\/\/[^/]+\/?/u)) { + return 'Malformed URL - incorrect protocol format'; + } + + // Validate hostname format. Accepts localhost and ports (needed for tests) + const hostname = url.hostname.toLowerCase(); + if ( + hostname !== 'localhost' && + (!hostname.includes('.') || + !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/u.test( + hostname, + )) + ) { + return 'Invalid hostname format'; + } + + // Check for protocol pollution in the entire URL + const decodedValue = decodeURIComponent(value.toLowerCase()); + if ( + value.includes('\\') || + value.includes('@') || + decodedValue.includes('\\') || + decodedValue.includes('@') || + value.toLowerCase().includes('%2f@') || + value.toLowerCase().includes('%5c@') + ) { + return 'URL contains protocol pollution attempts'; + } + + // Additional hostname safety check for protocol pollution + const decodedHostname = decodeURIComponent(hostname); + if ( + hostname.includes('\\') || + hostname.includes('@') || + decodedHostname.includes('/') || + hostname.toLowerCase().includes('%2f') + ) { + return 'Invalid hostname characters detected'; + } + + // Check for directory traversal + if ( + value.includes('../') || + value.includes('..%2f') || + value.includes('..%2F') + ) { + return 'Directory traversal attempts are not allowed'; + } + + // Check for dangerous patterns + const dangerousPatterns = [ + /<[^>]*>/u, // HTML tags + /javascript:/u, // JavaScript protocol + /data:/u, // Data URI scheme + /\\[@\\]/u, // Enhanced protocol pollution check + /%2f@/u, // Protocol pollution + /[^\x20-\x7E]/u, // Non-printable characters + /\$\{.*?\}/u, // Template injection + /#\{.*?\}/u, // Template injection + /[|;]/u, // Command injection + /%0[acd]|%0[acd]/u, // CRLF injection + /\/\/\w+@/u, // URL credential injection + // Enhanced SQL injection patterns + /(?:[^a-z]|^)(?:union\s+(?:all\s+)?select|select\s+(?:.*\s+)?from|insert\s+into|update\s+.*\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|exec(?:ute)?|union|where\s+[\d\w]\s*=\s*[\d\w]|\bor\b\s*[\d\w]\s*=\s*[\d\w])/iu, + /'.*?(?:OR|UNION|SELECT|FROM|WHERE).*?'/iu, // SQL injection + /%27.*?(?:OR|UNION|SELECT|FROM|WHERE).*?(?:%27|')/iu, // URL-encoded SQL injection + /%20(?:OR|UNION|SELECT|FROM|WHERE)%20/iu, // URL-encoded SQL keywords + /[?&](?:url|redirect|next|return_to|return_url|goto|destination|continue|redirect_uri)=(?:[^&]*\/\/|https?:)/iu, // Open redirect parameters + /[?&](?:url|redirect|next|return_to|return_url|goto|destination|continue|redirect_uri)=%(?:[^&]*\/\/|https?:)/iu, // URL-encoded open redirect parameters + ]; + + for (const patt of dangerousPatterns) { + if (patt.test(decodedValue)) { + return 'URL contains potentially malicious patterns'; + } + } + + // Port validation (if present) + if (url.port && !/^\d+$/u.test(url.port)) { + return 'Invalid port number'; + } + + return true; + } catch (error) { + return `Invalid URL format: ${(error as Error).message}`; + } +}); + +/** + * Keyring validations + */ +export const GetAccountStruct = object({ + accountId: UuidStruct, +}); +export const DeleteAccountStruct = object({ + accountId: UuidStruct, +}); +export const ListAccountAssetsStruct = object({ + accountId: UuidStruct, +}); +export const GetAccountBalancesStruct = object({ + accountId: UuidStruct, + assets: array(CaipAssetTypeStruct), +}); +export const ListAccountTransactionsStruct = object({ + accountId: UuidStruct, + pagination: object({ + limit: integer(), + next: optional(nullable(string())), + }), +}); + +export const NetworkStruct = enums(Object.values(Network)); + +/** + * Validates createAccount options. + * - entropySource: Optional string for the entropy source (UUID or ULID format) + * - index: Optional non-negative integer for account derivation index + */ +export const CreateAccountOptionsStruct = optional( + object({ + entropySource: optional(string()), + index: optional(integer()), + addressType: optional(enums([TrxAccountType.Eoa])), + scope: optional(NetworkStruct), + metamask: optional( + object({ + correlationId: optional(string()), + }), + ), + }), +); + +/** + * Validates discoverAccounts parameters. + * - scopes: Non-empty array of valid Tron network scopes (e.g., 'tron:728126428') + * - entropySource: String for the entropy source (UUID or ULID format) + * - groupIndex: Non-negative integer for the group index + */ +export const DiscoverAccountsStruct = object({ + scopes: nonempty(array(NetworkStruct)), + entropySource: string(), + groupIndex: min(number(), 0), +}); + +export const GetAccounBalancesResponseStruct = record( + CaipAssetTypeStruct, + object({ + amount: PositiveNumberStringStruct, + unit: string(), + }), +); + +export const ListAccountAssetsResponseStruct = array(CaipAssetTypeStruct); + +export const SubmitRequestMethodStruct = enums(Object.values(SolMethod)); + +export const Curenc = enums([ + 'btc', + 'eth', + 'ltc', + 'bch', + 'bnb', + 'eos', + 'xrp', + 'xlm', + 'link', + 'dot', + 'yfi', + 'usd', + 'aed', + 'ars', + 'aud', + 'bdt', + 'bhd', + 'bmd', + 'brl', + 'cad', + 'chf', + 'clp', + 'cny', + 'czk', + 'dkk', + 'eur', + 'gbp', + 'gel', + 'hkd', + 'huf', + 'idr', + 'ils', + 'inr', + 'jpy', + 'krw', + 'kwd', + 'lkr', + 'mmk', + 'mxn', + 'myr', + 'ngn', + 'nok', + 'nzd', + 'php', + 'pkr', + 'pln', + 'rub', + 'sar', + 'sek', + 'sgd', + 'thb', + 'try', + 'twd', + 'uah', + 'vef', + 'vnd', + 'zar', + 'xdr', + 'xag', + 'xau', + 'bits', + 'sats', +]); + +export const GetFeeForTransactionParamsStruct = object({ + transaction: string(), + scope: enums(Object.values(Network)), +}); + +export const GetFeeForTransactionResponseStruct = object({ + value: nullable(PositiveNumberStringStruct), +}); + +/** + * Validates if a string is Base58 encoded. + * Base58 uses alphanumeric characters excluding 0, O, I, and l. + */ +export const Base58Struct: Struct = define('Base58', (value) => { + const BASE_58_PATTERN = + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/u; + + // First check if it's a string + if (typeof value !== 'string') { + return `Expected a string, but received: ${typeof value}`; + } + + // Then check if it matches the Base58 pattern + if (!BASE_58_PATTERN.test(value)) { + return 'Expected a Base58 encoded string, but received a string with invalid characters'; + } + + return true; +}); + +/** + * Validates if a string is Base64 encoded. + * Base64 uses alphanumeric characters and +, /, and =. + * Empty strings are rejected. + */ +export const Base64Struct = pattern( + string(), + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/u, +); + +const DERIVATION_PATH_REGEX = /^m\/44'\/195'/u; + +export const ScopeStringStruct = enums(Object.values(Network)); + +/** + * Validates a Tron derivation path following the format: m/44'/195'/... + */ +export const DerivationPathStruct = pattern(string(), DERIVATION_PATH_REGEX); + +/** + * Validates an ISO 8601 date string. + */ +export const Iso8601Struct = pattern( + string(), + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/u, +); + +export const TronAddressStruct: Struct = define( + 'TronAddress', + (value) => { + if (typeof value !== 'string') { + return `Expected a string, but received: ${typeof value}`; + } + + // Use TronWeb's built-in address validation + const isValidAddress = TronWeb.isAddress(value); + + if (!isValidAddress) { + return 'Invalid Tron address format'; + } + + return true; + }, +); + +export const TronCaipAssetTypeStruct = union([ + NativeCaipAssetTypeStruct, + StakedCaipAssetTypeStruct, + TokenCaipAssetTypeStruct, + NftCaipAssetTypeStruct, + ResourceCaipAssetTypeStruct, + MaximumResourceCaipAssetTypeStruct, +]) as Struct; + +/** + * Multichain API - signMessage validation (params only) + */ +export const SignMessageRequestStruct = object({ + address: TronAddressStruct, + message: Base64Struct, +}); + +export const SignMessageResponseStruct = object({ + signature: string(), +}); + +/** + * Multichain API - signTransaction validation (params only) + */ +export const SignTransactionRequestStruct = object({ + address: TronAddressStruct, + transaction: object({ + rawDataHex: string(), + type: string(), + }), +}); + +/** + * Full signMessage request object (method + params) + */ +export const SignMessageRequestObjectStruct = object({ + method: literal(TronMultichainMethod.SignMessage), + params: SignMessageRequestStruct, +}); + +/** + * Full signTransaction request object (method + params) + */ +export const SignTransactionRequestObjectStruct = object({ + method: literal(TronMultichainMethod.SignTransaction), + params: SignTransactionRequestStruct, +}); + +/** + * Keyring Request validation for submitRequest + */ +export const TronKeyringRequestStruct = object({ + ...KeyringRequestStruct.schema, // Re-use default schema as a base. + scope: ScopeStringStruct, + request: union([ + SignMessageRequestObjectStruct, + SignTransactionRequestObjectStruct, + ]), +}); + +export type TronWalletKeyringRequest = Infer; + +/** + * Validation struct for resolveAccountAddress request + * Only validates the presence of method and params + * (other fields like jsonrpc, id, and extra params are allowed) + */ +export const ResolveAccountAddressRequestStruct = type({ + method: enums(Object.values(TronMultichainMethod)), + params: record(string(), unknown()), +}); + +/** + * Validation struct for resolveAccountAddress response + */ +export const ResolveAccountAddressResponseStruct = pattern( + string(), + /^[a-zA-Z0-9]+:[a-zA-Z0-9]+:[a-zA-Z0-9]+$/u, +); diff --git a/merged-packages/tron-wallet-snap/src/validation/transaction.test.ts b/merged-packages/tron-wallet-snap/src/validation/transaction.test.ts new file mode 100644 index 00000000..8adcf438 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/transaction.test.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { is } from '@metamask/superstruct'; +import { Types } from 'tronweb'; + +import { + assertTransactionStructure, + TransactionRawDataStruct, +} from './transaction'; + +/** + * Builds a well-formed raw transaction data object for testing. + * + * @param overrides - Partial overrides to apply. + * @returns A mock raw data object. + */ +function buildRawData( + overrides: Partial = {}, +): Types.Transaction['raw_data'] { + return { + contract: [ + { + type: Types.ContractType.TransferContract, + parameter: { + type_url: 'type.googleapis.com/protocol.TransferContract', + value: { + owner_address: '41a614f803b6fd780986a42c78ec9c7f77e6ded13c', + to_address: '4191bba2f3f6e1c4d5c8e8f5b6a7c8d9e0f1a2b3c4', + amount: 1000000, + }, + }, + }, + ], + ref_block_bytes: '', + ref_block_hash: '', + expiration: 0, + timestamp: 0, + ...overrides, + }; +} + +describe('TransactionRawDataStruct', () => { + it('accepts a well-formed single-contract transaction', () => { + expect(is(buildRawData(), TransactionRawDataStruct)).toBe(true); + }); + + it('rejects when contract array is empty', () => { + expect(is(buildRawData({ contract: [] }), TransactionRawDataStruct)).toBe( + false, + ); + }); + + it('rejects when there are multiple contracts', () => { + const singleContract = buildRawData(); + const rawData = buildRawData({ + contract: [...singleContract.contract, ...singleContract.contract], + }); + + expect(is(rawData, TransactionRawDataStruct)).toBe(false); + }); + + it('rejects when contract parameter value is missing', () => { + const rawData = buildRawData(); + (rawData.contract[0] as any).parameter.value = undefined; + + expect(is(rawData, TransactionRawDataStruct)).toBe(false); + }); + + it('rejects null input', () => { + expect(is(null, TransactionRawDataStruct)).toBe(false); + }); +}); + +describe('assertTransactionStructure', () => { + it('does not throw for a valid transaction', () => { + expect(() => assertTransactionStructure(buildRawData())).not.toThrow(); + }); + + it('throws InvalidParamsError for empty contracts', () => { + expect(() => + assertTransactionStructure(buildRawData({ contract: [] })), + ).toThrow(InvalidParamsError); + }); + + it('includes "Malformed transaction" in the error message', () => { + expect(() => + assertTransactionStructure(buildRawData({ contract: [] })), + ).toThrow(/Malformed transaction/u); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/validation/transaction.ts b/merged-packages/tron-wallet-snap/src/validation/transaction.ts new file mode 100644 index 00000000..da641570 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/transaction.ts @@ -0,0 +1,117 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { assert, define, is } from '@metamask/superstruct'; +import { TronWeb } from 'tronweb'; +import type { Types } from 'tronweb'; + +/** + * Superstruct validator for Tron transaction raw data. + * + * A well-formed transaction has exactly one contract entry with a + * non-empty parameter value. The Tron protocol only executes one + * contract per transaction; the array in TronWeb's type definition + * exists for forward-compatibility. Transactions with more than one + * contract or a missing parameter are considered malformed. + * + * @see https://developers.tron.network/docs/tron-protocol-transaction + */ +export const TransactionRawDataStruct = define( + 'TransactionRawData', + (value) => { + const rawData = value as Types.Transaction['raw_data']; + + if (!rawData?.contract || rawData.contract.length !== 1) { + return 'must contain exactly one contract'; + } + + const [contractInteraction] = rawData.contract; + if (!contractInteraction?.parameter?.value) { + return 'contract must have a non-empty parameter value'; + } + + return true; + }, +); + +/** + * Checks whether the transaction structure is well-formed. + * + * @param rawData - The raw transaction data. + * @returns True if the transaction has a valid single-contract structure. + */ +export function isTransactionWellFormed( + rawData: Types.Transaction['raw_data'], +): boolean { + return is(rawData, TransactionRawDataStruct); +} + +/** + * Asserts that the transaction raw data is well-formed, throwing if not. + * + * Use as a one-liner after deserializing an external transaction + * to reject malformed payloads before they reach signing or confirmation. + * + * @param rawData - The raw transaction data. + * @throws {InvalidParamsError} If the transaction is malformed. + */ +export function assertTransactionStructure( + rawData: Types.Transaction['raw_data'], +): void { + try { + assert(rawData, TransactionRawDataStruct); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'Unknown validation error'; + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw new InvalidParamsError(`Malformed transaction: ${message}`); + } +} + +/** + * Extracts the base58 owner address from raw transaction data. + * + * @param rawData - The raw transaction data. + * @returns The owner address in base58 format, or null if not found / invalid. + */ +export function extractTransactionOwnerAddress( + rawData: Types.Transaction['raw_data'], +): string | null { + const contract = rawData.contract[0]; + const ownerAddressHex = contract?.parameter?.value?.owner_address; + + if (!ownerAddressHex) { + return null; + } + + try { + return TronWeb.address.fromHex(ownerAddressHex); + } catch { + return null; + } +} + +/** + * Verifies that the `owner_address` extracted from the transaction's `raw_data` matches the persisted account address + * + * @param rawData - The raw transaction data. + * @param signerAddress - The address derived from the private key used to sign. + */ +export function assertTransactionSignerConsistency( + rawData: Types.Transaction['raw_data'], + signerAddress: string, +): void { + const transactionOwnerAddress = extractTransactionOwnerAddress(rawData); + + if (!transactionOwnerAddress) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw new InvalidParamsError( + `'Transaction is missing owner_address - cannot verify sender`, + ); + } + + if (transactionOwnerAddress !== signerAddress) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw new InvalidParamsError( + `Transaction owner_address (${transactionOwnerAddress}) does not match derived signer address (${signerAddress})`, + ); + } +} diff --git a/merged-packages/tron-wallet-snap/src/validation/validators.test.ts b/merged-packages/tron-wallet-snap/src/validation/validators.test.ts new file mode 100644 index 00000000..1c623942 --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/validators.test.ts @@ -0,0 +1,12 @@ +import { ListAccountAssetsResponseStruct } from './structs'; +import { validateResponse } from './validators'; + +describe('Validators', () => { + describe('validateResponse', () => { + it('throws invalid response', () => { + expect(() => + validateResponse({}, ListAccountAssetsResponseStruct), + ).toThrow(`Invalid Response: Expected an array value`); + }); + }); +}); diff --git a/merged-packages/tron-wallet-snap/src/validation/validators.ts b/merged-packages/tron-wallet-snap/src/validation/validators.ts new file mode 100644 index 00000000..667bdc4a --- /dev/null +++ b/merged-packages/tron-wallet-snap/src/validation/validators.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/only-throw-error */ +import { InvalidParamsError, UnauthorizedError } from '@metamask/snaps-sdk'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; + +import { originPermissions } from '../permissions'; + +export const validateOrigin = (origin: string, method: string): void => { + if (!origin) { + throw new UnauthorizedError('Origin not found'); + } + if (!originPermissions.get(origin)?.has(method)) { + throw new UnauthorizedError('Permission denied'); + } +}; + +/** + * Validates that the request parameters conform to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the request parameters. + * @param requestParams - The request parameters to validate. + * @param struct - The expected structure of the request parameters. + * @throws {typeof InvalidParamsError} If the request parameters do not conform to the expected structure. + */ +// TODO: Replace `any` with type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function validateRequest>( + requestParams: Params, + struct: TStruct, +): asserts requestParams is Infer { + try { + assert(requestParams, struct); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (validationError: any) { + throw new InvalidParamsError(validationError.message); + } +} + +/** + * Validates that the response conforms to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the response. + * @param response - The response to validate. + * @param struct - The expected structure of the response. + * @throws Error if the response does not conform to the expected structure. + */ +// TODO: Replace `any` with type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function validateResponse>( + response: Params, + struct: TStruct, +): asserts response is Infer { + try { + assert(response, struct); + } catch (error) { + throw new Error(`Invalid Response: ${(error as Error).message}`, { + cause: error, + }); + } +} diff --git a/merged-packages/tron-wallet-snap/tsconfig.json b/merged-packages/tron-wallet-snap/tsconfig.json new file mode 100644 index 00000000..99b45e34 --- /dev/null +++ b/merged-packages/tron-wallet-snap/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "resolveJsonModule": true /* lets us import JSON modules from within TypeScript modules. */, + "jsx": "react-jsx", + "jsxImportSource": "@metamask/snaps-sdk", + "exactOptionalPropertyTypes": false, + "types": ["jest"] + }, + "include": ["**/*.ts", "**/*.tsx", "locales/*.json"] +}